diff --git a/erpnext/accounts/doctype/process_subscription/__init__.py b/erpnext/accounts/doctype/process_subscription/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/accounts/doctype/process_subscription/process_subscription.js b/erpnext/accounts/doctype/process_subscription/process_subscription.js new file mode 100644 index 0000000000..858c91334b --- /dev/null +++ b/erpnext/accounts/doctype/process_subscription/process_subscription.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Process Subscription", { +// refresh(frm) { + +// }, +// }); diff --git a/erpnext/accounts/doctype/process_subscription/process_subscription.json b/erpnext/accounts/doctype/process_subscription/process_subscription.json new file mode 100644 index 0000000000..502d00286b --- /dev/null +++ b/erpnext/accounts/doctype/process_subscription/process_subscription.json @@ -0,0 +1,90 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-09-17 15:40:59.724177", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "posting_date", + "subscription", + "amended_from" + ], + "fields": [ + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Process Subscription", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "posting_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Posting Date", + "reqd": 1 + }, + { + "fieldname": "subscription", + "fieldtype": "Link", + "label": "Subscription", + "options": "Subscription" + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2023-09-17 17:33:37.974166", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Process Subscription", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "submit": 1, + "write": 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, + "submit": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/process_subscription/process_subscription.py b/erpnext/accounts/doctype/process_subscription/process_subscription.py new file mode 100644 index 0000000000..99269d6a7d --- /dev/null +++ b/erpnext/accounts/doctype/process_subscription/process_subscription.py @@ -0,0 +1,27 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from datetime import datetime +from typing import Union + +import frappe +from frappe.model.document import Document +from frappe.utils import getdate + +from erpnext.accounts.doctype.subscription.subscription import process_all + + +class ProcessSubscription(Document): + def on_submit(self): + process_all(subscription=self.subscription, posting_date=self.posting_date) + + +def create_subscription_process( + subscription: str | None, posting_date: Union[str, datetime.date] | None +): + """Create a new Process Subscription document""" + doc = frappe.new_doc("Process Subscription") + doc.subscription = subscription + doc.posting_date = getdate(posting_date) + doc.insert(ignore_permissions=True) + doc.submit() diff --git a/erpnext/accounts/doctype/process_subscription/test_process_subscription.py b/erpnext/accounts/doctype/process_subscription/test_process_subscription.py new file mode 100644 index 0000000000..723695f1ab --- /dev/null +++ b/erpnext/accounts/doctype/process_subscription/test_process_subscription.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestProcessSubscription(FrappeTestCase): + pass diff --git a/erpnext/accounts/doctype/subscription/subscription.json b/erpnext/accounts/doctype/subscription/subscription.json index c15aa1e05a..187b7abce1 100644 --- a/erpnext/accounts/doctype/subscription/subscription.json +++ b/erpnext/accounts/doctype/subscription/subscription.json @@ -24,8 +24,9 @@ "current_invoice_start", "current_invoice_end", "days_until_due", + "generate_invoice_at", + "number_of_days", "cancel_at_period_end", - "generate_invoice_at_period_start", "sb_4", "plans", "sb_1", @@ -86,12 +87,14 @@ "fieldname": "current_invoice_start", "fieldtype": "Date", "label": "Current Invoice Start Date", + "no_copy": 1, "read_only": 1 }, { "fieldname": "current_invoice_end", "fieldtype": "Date", "label": "Current Invoice End Date", + "no_copy": 1, "read_only": 1 }, { @@ -107,12 +110,6 @@ "fieldtype": "Check", "label": "Cancel At End Of Period" }, - { - "default": "0", - "fieldname": "generate_invoice_at_period_start", - "fieldtype": "Check", - "label": "Generate Invoice At Beginning Of Period" - }, { "allow_on_submit": 1, "fieldname": "sb_4", @@ -240,6 +237,21 @@ "fieldname": "submit_invoice", "fieldtype": "Check", "label": "Submit Generated Invoices" + }, + { + "default": "End of the current subscription period", + "fieldname": "generate_invoice_at", + "fieldtype": "Select", + "label": "Generate Invoice At", + "options": "End of the current subscription period\nBeginning of the current subscription period\nDays before the current subscription period", + "reqd": 1 + }, + { + "depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\"", + "fieldname": "number_of_days", + "fieldtype": "Int", + "label": "Number of Days", + "mandatory_depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\"" } ], "index_web_pages_for_search": 1, @@ -255,7 +267,7 @@ "link_fieldname": "subscription" } ], - "modified": "2022-02-18 23:24:57.185054", + "modified": "2023-09-18 17:48:21.900252", "modified_by": "Administrator", "module": "Accounts", "name": "Subscription", diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py index bbcade1758..3cf7d284bb 100644 --- a/erpnext/accounts/doctype/subscription/subscription.py +++ b/erpnext/accounts/doctype/subscription/subscription.py @@ -36,12 +36,15 @@ class InvoiceNotCancelled(frappe.ValidationError): pass +DateTimeLikeObject = Union[str, datetime.date] + + class Subscription(Document): def before_insert(self): # update start just before the subscription doc is created self.update_subscription_period(self.start_date) - def update_subscription_period(self, date: Optional[Union[datetime.date, str]] = None): + def update_subscription_period(self, date: Optional["DateTimeLikeObject"] = None): """ Subscription period is the period to be billed. This method updates the beginning of the billing period and end of the billing period. @@ -52,14 +55,14 @@ class Subscription(Document): self.current_invoice_start = self.get_current_invoice_start(date) self.current_invoice_end = self.get_current_invoice_end(self.current_invoice_start) - def _get_subscription_period(self, date: Optional[Union[datetime.date, str]] = None): + def _get_subscription_period(self, date: Optional["DateTimeLikeObject"] = None): _current_invoice_start = self.get_current_invoice_start(date) _current_invoice_end = self.get_current_invoice_end(_current_invoice_start) return _current_invoice_start, _current_invoice_end def get_current_invoice_start( - self, date: Optional[Union[datetime.date, str]] = None + self, date: Optional["DateTimeLikeObject"] = None ) -> Union[datetime.date, str]: """ This returns the date of the beginning of the current billing period. @@ -84,7 +87,7 @@ class Subscription(Document): return _current_invoice_start def get_current_invoice_end( - self, date: Optional[Union[datetime.date, str]] = None + self, date: Optional["DateTimeLikeObject"] = None ) -> Union[datetime.date, str]: """ This returns the date of the end of the current billing period. @@ -179,30 +182,24 @@ class Subscription(Document): return data - def set_subscription_status(self) -> None: + def set_subscription_status(self, posting_date: Optional["DateTimeLikeObject"] = None) -> None: """ Sets the status of the `Subscription` """ if self.is_trialling(): self.status = "Trialling" elif ( - self.status == "Active" - and self.end_date - and getdate(frappe.flags.current_date) > getdate(self.end_date) + self.status == "Active" and self.end_date and getdate(posting_date) > getdate(self.end_date) ): self.status = "Completed" elif self.is_past_grace_period(): self.status = self.get_status_for_past_grace_period() - self.cancelation_date = ( - getdate(frappe.flags.current_date) if self.status == "Cancelled" else None - ) + self.cancelation_date = getdate(posting_date) if self.status == "Cancelled" else None elif self.current_invoice_is_past_due() and not self.is_past_grace_period(): self.status = "Past Due Date" elif not self.has_outstanding_invoice() or self.is_new_subscription(): self.status = "Active" - self.save() - def is_trialling(self) -> bool: """ Returns `True` if the `Subscription` is in trial period. @@ -210,7 +207,9 @@ class Subscription(Document): return not self.period_has_passed(self.trial_period_end) and self.is_new_subscription() @staticmethod - def period_has_passed(end_date: Union[str, datetime.date]) -> bool: + def period_has_passed( + end_date: Union[str, datetime.date], posting_date: Optional["DateTimeLikeObject"] = None + ) -> bool: """ Returns true if the given `end_date` has passed """ @@ -218,7 +217,7 @@ class Subscription(Document): if not end_date: return True - return getdate(frappe.flags.current_date) > getdate(end_date) + return getdate(posting_date) > getdate(end_date) def get_status_for_past_grace_period(self) -> str: cancel_after_grace = cint(frappe.get_value("Subscription Settings", None, "cancel_after_grace")) @@ -229,7 +228,7 @@ class Subscription(Document): return status - def is_past_grace_period(self) -> bool: + def is_past_grace_period(self, posting_date: Optional["DateTimeLikeObject"] = None) -> bool: """ Returns `True` if the grace period for the `Subscription` has passed """ @@ -237,18 +236,18 @@ class Subscription(Document): return grace_period = cint(frappe.get_value("Subscription Settings", None, "grace_period")) - return getdate(frappe.flags.current_date) >= getdate( - add_days(self.current_invoice.due_date, grace_period) - ) + return getdate(posting_date) >= getdate(add_days(self.current_invoice.due_date, grace_period)) - def current_invoice_is_past_due(self) -> bool: + def current_invoice_is_past_due( + self, posting_date: Optional["DateTimeLikeObject"] = None + ) -> bool: """ Returns `True` if the current generated invoice is overdue """ if not self.current_invoice or self.is_paid(self.current_invoice): return False - return getdate(frappe.flags.current_date) >= getdate(self.current_invoice.due_date) + return getdate(posting_date) >= getdate(self.current_invoice.due_date) @property def invoice_document_type(self) -> str: @@ -270,6 +269,9 @@ class Subscription(Document): if not self.cost_center: self.cost_center = get_default_cost_center(self.get("company")) + if self.is_new(): + self.set_subscription_status() + def validate_trial_period(self) -> None: """ Runs sanity checks on trial period dates for the `Subscription` @@ -305,10 +307,6 @@ class Subscription(Document): if billing_info[0]["billing_interval"] != "Month": frappe.throw(_("Billing Interval in Subscription Plan must be Month to follow calendar months")) - def after_insert(self) -> None: - # todo: deal with users who collect prepayments. Maybe a new Subscription Invoice doctype? - self.set_subscription_status() - def generate_invoice( self, from_date: Optional[Union[str, datetime.date]] = None, @@ -344,7 +342,7 @@ class Subscription(Document): invoice.set_posting_time = 1 invoice.posting_date = ( self.current_invoice_start - if self.generate_invoice_at_period_start + if self.generate_invoice_at == "Beginning of the current subscription period" else self.current_invoice_end ) @@ -438,7 +436,7 @@ class Subscription(Document): prorate_factor = get_prorata_factor( self.current_invoice_end, self.current_invoice_start, - cint(self.generate_invoice_at_period_start), + cint(self.generate_invoice_at == "Beginning of the current subscription period"), ) items = [] @@ -503,42 +501,45 @@ class Subscription(Document): return items @frappe.whitelist() - def process(self) -> bool: + def process(self, posting_date: Optional["DateTimeLikeObject"] = None) -> bool: """ To be called by task periodically. It checks the subscription and takes appropriate action as need be. It calls either of these methods depending the `Subscription` status: 1. `process_for_active` 2. `process_for_past_due` """ - if ( - not self.is_current_invoice_generated(self.current_invoice_start, self.current_invoice_end) - and self.can_generate_new_invoice() - ): + if not self.is_current_invoice_generated( + self.current_invoice_start, self.current_invoice_end + ) and self.can_generate_new_invoice(posting_date): self.generate_invoice() self.update_subscription_period(add_days(self.current_invoice_end, 1)) if self.cancel_at_period_end and ( - getdate(frappe.flags.current_date) >= getdate(self.current_invoice_end) - or getdate(frappe.flags.current_date) >= getdate(self.end_date) + getdate(posting_date) >= getdate(self.current_invoice_end) + or getdate(posting_date) >= getdate(self.end_date) ): self.cancel_subscription() - self.set_subscription_status() + self.set_subscription_status(posting_date=posting_date) self.save() - def can_generate_new_invoice(self) -> bool: + def can_generate_new_invoice(self, posting_date: Optional["DateTimeLikeObject"] = None) -> bool: if self.cancelation_date: return False - elif self.generate_invoice_at_period_start and ( - getdate(frappe.flags.current_date) == getdate(self.current_invoice_start) - or self.is_new_subscription() + + if self.has_outstanding_invoice() and not self.generate_new_invoices_past_due_date: + return False + + if self.generate_invoice_at == "Beginning of the current subscription period" and ( + getdate(posting_date) == getdate(self.current_invoice_start) or self.is_new_subscription() ): return True - elif getdate(frappe.flags.current_date) == getdate(self.current_invoice_end): - if self.has_outstanding_invoice() and not self.generate_new_invoices_past_due_date: - return False - + elif self.generate_invoice_at == "Days before the current subscription period" and ( + getdate(posting_date) == getdate(add_days(self.current_invoice_start, -1 * self.number_of_days)) + ): + return True + elif getdate(posting_date) == getdate(self.current_invoice_end): return True else: return False @@ -628,7 +629,10 @@ class Subscription(Document): frappe.throw(_("subscription is already cancelled."), InvoiceCancelled) to_generate_invoice = ( - True if self.status == "Active" and not self.generate_invoice_at_period_start else False + True + if self.status == "Active" + and not self.generate_invoice_at == "Beginning of the current subscription period" + else False ) self.status = "Cancelled" self.cancelation_date = nowdate() @@ -639,7 +643,7 @@ class Subscription(Document): self.save() @frappe.whitelist() - def restart_subscription(self) -> None: + def restart_subscription(self, posting_date: Optional["DateTimeLikeObject"] = None) -> None: """ This sets the subscription as active. The subscription will be made to be like a new subscription and the `Subscription` will lose all the history of generated invoices @@ -650,7 +654,7 @@ class Subscription(Document): self.status = "Active" self.cancelation_date = None - self.update_subscription_period(frappe.flags.current_date or nowdate()) + self.update_subscription_period(posting_date or nowdate()) self.save() @@ -671,14 +675,21 @@ def get_prorata_factor( return diff / plan_days -def process_all() -> None: +def process_all( + subscription: str | None, posting_date: Optional["DateTimeLikeObject"] = None +) -> None: """ Task to updates the status of all `Subscription` apart from those that are cancelled """ - for subscription in frappe.get_all("Subscription", {"status": ("!=", "Cancelled")}, pluck="name"): + filters = {"status": ("!=", "Cancelled")} + + if subscription: + filters["name"] = subscription + + for subscription in frappe.get_all("Subscription", filters, pluck="name"): try: subscription = frappe.get_doc("Subscription", subscription) - subscription.process() + subscription.process(posting_date) frappe.db.commit() except frappe.ValidationError: frappe.db.rollback() diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py index 0bb171f464..803e87900d 100644 --- a/erpnext/accounts/doctype/subscription/test_subscription.py +++ b/erpnext/accounts/doctype/subscription/test_subscription.py @@ -8,6 +8,7 @@ from frappe.utils.data import ( add_days, add_months, add_to_date, + cint, date_diff, flt, get_date_str, @@ -20,99 +21,16 @@ from erpnext.accounts.doctype.subscription.subscription import get_prorata_facto test_dependencies = ("UOM", "Item Group", "Item") -def create_plan(): - if not frappe.db.exists("Subscription Plan", "_Test Plan Name"): - plan = frappe.new_doc("Subscription Plan") - plan.plan_name = "_Test Plan Name" - plan.item = "_Test Non Stock Item" - plan.price_determination = "Fixed Rate" - plan.cost = 900 - plan.billing_interval = "Month" - plan.billing_interval_count = 1 - plan.insert() - - if not frappe.db.exists("Subscription Plan", "_Test Plan Name 2"): - plan = frappe.new_doc("Subscription Plan") - plan.plan_name = "_Test Plan Name 2" - plan.item = "_Test Non Stock Item" - plan.price_determination = "Fixed Rate" - plan.cost = 1999 - plan.billing_interval = "Month" - plan.billing_interval_count = 1 - plan.insert() - - if not frappe.db.exists("Subscription Plan", "_Test Plan Name 3"): - plan = frappe.new_doc("Subscription Plan") - plan.plan_name = "_Test Plan Name 3" - plan.item = "_Test Non Stock Item" - plan.price_determination = "Fixed Rate" - plan.cost = 1999 - plan.billing_interval = "Day" - plan.billing_interval_count = 14 - plan.insert() - - # Defined a quarterly Subscription Plan - if not frappe.db.exists("Subscription Plan", "_Test Plan Name 4"): - plan = frappe.new_doc("Subscription Plan") - plan.plan_name = "_Test Plan Name 4" - plan.item = "_Test Non Stock Item" - plan.price_determination = "Monthly Rate" - plan.cost = 20000 - plan.billing_interval = "Month" - plan.billing_interval_count = 3 - plan.insert() - - if not frappe.db.exists("Subscription Plan", "_Test Plan Multicurrency"): - plan = frappe.new_doc("Subscription Plan") - plan.plan_name = "_Test Plan Multicurrency" - plan.item = "_Test Non Stock Item" - plan.price_determination = "Fixed Rate" - plan.cost = 50 - plan.currency = "USD" - plan.billing_interval = "Month" - plan.billing_interval_count = 1 - plan.insert() - - -def create_parties(): - if not frappe.db.exists("Supplier", "_Test Supplier"): - supplier = frappe.new_doc("Supplier") - supplier.supplier_name = "_Test Supplier" - supplier.supplier_group = "All Supplier Groups" - supplier.insert() - - if not frappe.db.exists("Customer", "_Test Subscription Customer"): - customer = frappe.new_doc("Customer") - customer.customer_name = "_Test Subscription Customer" - customer.billing_currency = "USD" - customer.append( - "accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"} - ) - customer.insert() - - -def reset_settings(): - settings = frappe.get_single("Subscription Settings") - settings.grace_period = 0 - settings.cancel_after_grace = 0 - settings.save() - - class TestSubscription(unittest.TestCase): def setUp(self): - create_plan() + make_plans() create_parties() reset_settings() def test_create_subscription_with_trial_with_correct_period(self): - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.trial_period_start = nowdate() - subscription.trial_period_end = add_months(nowdate(), 1) - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.save() - + subscription = create_subscription( + trial_period_start=nowdate(), trial_period_end=add_months(nowdate(), 1) + ) self.assertEqual(subscription.trial_period_start, nowdate()) self.assertEqual(subscription.trial_period_end, add_months(nowdate(), 1)) self.assertEqual( @@ -126,12 +44,7 @@ class TestSubscription(unittest.TestCase): self.assertEqual(subscription.status, "Trialling") def test_create_subscription_without_trial_with_correct_period(self): - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.save() - + subscription = create_subscription() self.assertEqual(subscription.trial_period_start, None) self.assertEqual(subscription.trial_period_end, None) self.assertEqual(subscription.current_invoice_start, nowdate()) @@ -141,55 +54,28 @@ class TestSubscription(unittest.TestCase): self.assertEqual(subscription.status, "Active") def test_create_subscription_trial_with_wrong_dates(self): - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.trial_period_end = nowdate() - subscription.trial_period_start = add_days(nowdate(), 30) - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - - self.assertRaises(frappe.ValidationError, subscription.save) - - def test_create_subscription_multi_with_different_billing_fails(self): - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.trial_period_end = nowdate() - subscription.trial_period_start = add_days(nowdate(), 30) - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.append("plans", {"plan": "_Test Plan Name 3", "qty": 1}) - + subscription = create_subscription( + trial_period_start=add_days(nowdate(), 30), trial_period_end=nowdate(), do_not_save=True + ) self.assertRaises(frappe.ValidationError, subscription.save) def test_invoice_is_generated_at_end_of_billing_period(self): - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.start_date = "2018-01-01" - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.insert() - + subscription = create_subscription(start_date="2018-01-01") self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.current_invoice_start, "2018-01-01") self.assertEqual(subscription.current_invoice_end, "2018-01-31") - frappe.flags.current_date = "2018-01-31" - subscription.process() + subscription.process(posting_date="2018-01-31") self.assertEqual(len(subscription.invoices), 1) self.assertEqual(subscription.current_invoice_start, "2018-02-01") self.assertEqual(subscription.current_invoice_end, "2018-02-28") self.assertEqual(subscription.status, "Unpaid") def test_status_goes_back_to_active_after_invoice_is_paid(self): - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.start_date = "2018-01-01" - subscription.generate_invoice_at_period_start = True - subscription.insert() - frappe.flags.current_date = "2018-01-01" - subscription.process() # generate first invoice + subscription = create_subscription( + start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period" + ) + subscription.process(posting_date="2018-01-01") # generate first invoice self.assertEqual(len(subscription.invoices), 1) # Status is unpaid as Days until Due is zero and grace period is Zero @@ -213,18 +99,10 @@ class TestSubscription(unittest.TestCase): settings.cancel_after_grace = 1 settings.save() - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - # subscription.generate_invoice_at_period_start = True - subscription.start_date = "2018-01-01" - subscription.insert() - + subscription = create_subscription(start_date="2018-01-01") self.assertEqual(subscription.status, "Active") - frappe.flags.current_date = "2018-01-31" - subscription.process() # generate first invoice + subscription.process(posting_date="2018-01-31") # generate first invoice # This should change status to Cancelled since grace period is 0 # And is backdated subscription so subscription will be cancelled after processing self.assertEqual(subscription.status, "Cancelled") @@ -235,13 +113,8 @@ class TestSubscription(unittest.TestCase): settings.cancel_after_grace = 0 settings.save() - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.start_date = "2018-01-01" - subscription.insert() - subscription.process() # generate first invoice + subscription = create_subscription(start_date="2018-01-01") + subscription.process(posting_date="2018-01-31") # generate first invoice # Status is unpaid as Days until Due is zero and grace period is Zero self.assertEqual(subscription.status, "Unpaid") @@ -251,21 +124,9 @@ class TestSubscription(unittest.TestCase): def test_subscription_invoice_days_until_due(self): _date = add_months(nowdate(), -1) - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.days_until_due = 10 - subscription.start_date = _date - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.insert() + subscription = create_subscription(start_date=_date, days_until_due=10) - frappe.flags.current_date = subscription.current_invoice_end - - subscription.process() # generate first invoice - self.assertEqual(len(subscription.invoices), 1) - self.assertEqual(subscription.status, "Active") - - frappe.flags.current_date = add_days(subscription.current_invoice_end, 3) + subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice self.assertEqual(len(subscription.invoices), 1) self.assertEqual(subscription.status, "Active") @@ -275,16 +136,9 @@ class TestSubscription(unittest.TestCase): settings.grace_period = 1000 settings.save() - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.start_date = add_days(nowdate(), -1000) - subscription.insert() - - frappe.flags.current_date = subscription.current_invoice_end - subscription.process() # generate first invoice + subscription = create_subscription(start_date=add_days(nowdate(), -1000)) + subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice self.assertEqual(subscription.status, "Past Due Date") subscription.process() @@ -301,12 +155,7 @@ class TestSubscription(unittest.TestCase): settings.save() def test_subscription_remains_active_during_invoice_period(self): - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.save() - subscription.process() # no changes expected + subscription = create_subscription() # no changes expected self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.current_invoice_start, nowdate()) @@ -325,12 +174,8 @@ class TestSubscription(unittest.TestCase): self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1)) self.assertEqual(len(subscription.invoices), 0) - def test_subscription_cancelation(self): - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.save() + def test_subscription_cancellation(self): + subscription = create_subscription() subscription.cancel_subscription() self.assertEqual(subscription.status, "Cancelled") @@ -341,11 +186,7 @@ class TestSubscription(unittest.TestCase): settings.prorate = 1 settings.save() - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.save() + subscription = create_subscription() self.assertEqual(subscription.status, "Active") @@ -365,7 +206,7 @@ class TestSubscription(unittest.TestCase): get_prorata_factor( subscription.current_invoice_end, subscription.current_invoice_start, - subscription.generate_invoice_at_period_start, + cint(subscription.generate_invoice_at == "Beginning of the current subscription period"), ), 2, ), @@ -383,11 +224,7 @@ class TestSubscription(unittest.TestCase): settings.prorate = 0 settings.save() - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.save() + subscription = create_subscription() subscription.cancel_subscription() invoice = subscription.get_current_invoice() @@ -402,11 +239,7 @@ class TestSubscription(unittest.TestCase): settings.prorate = 1 settings.save() - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.save() + subscription = create_subscription() subscription.cancel_subscription() invoice = subscription.get_current_invoice() @@ -421,18 +254,13 @@ class TestSubscription(unittest.TestCase): settings.prorate = to_prorate settings.save() - def test_subcription_cancellation_and_process(self): + def test_subscription_cancellation_and_process(self): settings = frappe.get_single("Subscription Settings") default_grace_period_action = settings.cancel_after_grace settings.cancel_after_grace = 1 settings.save() - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.start_date = "2018-01-01" - subscription.insert() + subscription = create_subscription(start_date="2018-01-01") subscription.process() # generate first invoice # Generate an invoice for the cancelled period @@ -458,14 +286,8 @@ class TestSubscription(unittest.TestCase): settings.cancel_after_grace = 0 settings.save() - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.start_date = "2018-01-01" - subscription.insert() - frappe.flags.current_date = "2018-01-31" - subscription.process() # generate first invoice + subscription = create_subscription(start_date="2018-01-01") + subscription.process(posting_date="2018-01-31") # generate first invoice # Status is unpaid as Days until Due is zero and grace period is Zero self.assertEqual(subscription.status, "Unpaid") @@ -494,17 +316,10 @@ class TestSubscription(unittest.TestCase): settings.cancel_after_grace = 0 settings.save() - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.start_date = "2018-01-01" - subscription.generate_invoice_at_period_start = True - subscription.insert() - - frappe.flags.current_date = subscription.current_invoice_start - - subscription.process() # generate first invoice + subscription = create_subscription( + start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period" + ) + subscription.process(subscription.current_invoice_start) # generate first invoice # This should change status to Unpaid since grace period is 0 self.assertEqual(subscription.status, "Unpaid") @@ -516,29 +331,18 @@ class TestSubscription(unittest.TestCase): self.assertEqual(subscription.status, "Active") # A new invoice is generated - frappe.flags.current_date = subscription.current_invoice_start - subscription.process() + subscription.process(posting_date=subscription.current_invoice_start) self.assertEqual(subscription.status, "Unpaid") settings.cancel_after_grace = default_grace_period_action settings.save() def test_restart_active_subscription(self): - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.save() - + subscription = create_subscription() self.assertRaises(frappe.ValidationError, subscription.restart_subscription) def test_subscription_invoice_discount_percentage(self): - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.additional_discount_percentage = 10 - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.save() + subscription = create_subscription(additional_discount_percentage=10) subscription.cancel_subscription() invoice = subscription.get_current_invoice() @@ -547,12 +351,7 @@ class TestSubscription(unittest.TestCase): self.assertEqual(invoice.apply_discount_on, "Grand Total") def test_subscription_invoice_discount_amount(self): - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.additional_discount_amount = 11 - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.save() + subscription = create_subscription(additional_discount_amount=11) subscription.cancel_subscription() invoice = subscription.get_current_invoice() @@ -563,18 +362,13 @@ class TestSubscription(unittest.TestCase): def test_prepaid_subscriptions(self): # Create a non pre-billed subscription, processing should not create # invoices. - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.save() + subscription = create_subscription() subscription.process() - self.assertEqual(len(subscription.invoices), 0) # Change the subscription type to prebilled and process it. # Prepaid invoice should be generated - subscription.generate_invoice_at_period_start = True + subscription.generate_invoice_at = "Beginning of the current subscription period" subscription.save() subscription.process() @@ -586,12 +380,9 @@ class TestSubscription(unittest.TestCase): settings.prorate = 1 settings.save() - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Customer" - subscription.generate_invoice_at_period_start = True - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.save() + subscription = create_subscription( + generate_invoice_at="Beginning of the current subscription period" + ) subscription.process() subscription.cancel_subscription() @@ -609,9 +400,10 @@ class TestSubscription(unittest.TestCase): def test_subscription_with_follow_calendar_months(self): subscription = frappe.new_doc("Subscription") + subscription.company = "_Test Company" subscription.party_type = "Supplier" subscription.party = "_Test Supplier" - subscription.generate_invoice_at_period_start = 1 + subscription.generate_invoice_at = "Beginning of the current subscription period" subscription.follow_calendar_months = 1 # select subscription start date as "2018-01-15" @@ -625,39 +417,33 @@ class TestSubscription(unittest.TestCase): self.assertEqual(get_date_str(subscription.current_invoice_end), "2018-03-31") def test_subscription_generate_invoice_past_due(self): - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Supplier" - subscription.party = "_Test Supplier" - subscription.generate_invoice_at_period_start = 1 - subscription.generate_new_invoices_past_due_date = 1 - # select subscription start date as "2018-01-15" - subscription.start_date = "2018-01-01" - subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1}) - subscription.save() + subscription = create_subscription( + start_date="2018-01-01", + party_type="Supplier", + party="_Test Supplier", + generate_invoice_at="Beginning of the current subscription period", + generate_new_invoices_past_due_date=1, + plans=[{"plan": "_Test Plan Name 4", "qty": 1}], + ) - frappe.flags.current_date = "2018-01-01" # Process subscription and create first invoice # Subscription status will be unpaid since due date has already passed - subscription.process() + subscription.process(posting_date="2018-01-01") self.assertEqual(len(subscription.invoices), 1) self.assertEqual(subscription.status, "Unpaid") # Now the Subscription is unpaid # Even then new invoice should be created as we have enabled `generate_new_invoices_past_due_date` in # subscription and the interval between the subscriptions is 3 months - frappe.flags.current_date = "2018-04-01" - subscription.process() + subscription.process(posting_date="2018-04-01") self.assertEqual(len(subscription.invoices), 2) def test_subscription_without_generate_invoice_past_due(self): - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Supplier" - subscription.party = "_Test Supplier" - subscription.generate_invoice_at_period_start = 1 - # select subscription start date as "2018-01-15" - subscription.start_date = "2018-01-01" - subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1}) - subscription.save() + subscription = create_subscription( + start_date="2018-01-01", + generate_invoice_at="Beginning of the current subscription period", + plans=[{"plan": "_Test Plan Name 4", "qty": 1}], + ) # Process subscription and create first invoice # Subscription status will be unpaid since due date has already passed @@ -668,16 +454,13 @@ class TestSubscription(unittest.TestCase): subscription.process() self.assertEqual(len(subscription.invoices), 1) - def test_multicurrency_subscription(self): - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Subscription Customer" - subscription.generate_invoice_at_period_start = 1 - subscription.company = "_Test Company" - # select subscription start date as "2018-01-15" - subscription.start_date = "2018-01-01" - subscription.append("plans", {"plan": "_Test Plan Multicurrency", "qty": 1}) - subscription.save() + def test_multi_currency_subscription(self): + subscription = create_subscription( + start_date="2018-01-01", + generate_invoice_at="Beginning of the current subscription period", + plans=[{"plan": "_Test Plan Multicurrency", "qty": 1}], + party="_Test Subscription Customer", + ) subscription.process() self.assertEqual(len(subscription.invoices), 1) @@ -689,42 +472,135 @@ class TestSubscription(unittest.TestCase): def test_subscription_recovery(self): """Test if Subscription recovers when start/end date run out of sync with created invoices.""" - subscription = frappe.new_doc("Subscription") - subscription.party_type = "Customer" - subscription.party = "_Test Subscription Customer" - subscription.company = "_Test Company" - subscription.start_date = "2021-12-01" - subscription.generate_new_invoices_past_due_date = 1 - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) - subscription.submit_invoice = 0 - subscription.save() + subscription = create_subscription( + start_date="2021-01-01", + submit_invoice=0, + generate_new_invoices_past_due_date=1, + party="_Test Subscription Customer", + ) # create invoices for the first two moths - frappe.flags.current_date = "2021-12-31" - subscription.process() + subscription.process(posting_date="2021-01-31") - frappe.flags.current_date = "2022-01-31" - subscription.process() + subscription.process(posting_date="2021-02-28") self.assertEqual(len(subscription.invoices), 2) self.assertEqual( getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")), - getdate("2021-12-01"), + getdate("2021-01-01"), ) self.assertEqual( getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")), - getdate("2022-01-01"), + getdate("2021-02-01"), ) # recreate most recent invoice - subscription.process() + subscription.process(posting_date="2022-01-31") self.assertEqual(len(subscription.invoices), 2) self.assertEqual( getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")), - getdate("2021-12-01"), + getdate("2021-01-01"), ) self.assertEqual( getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")), - getdate("2022-01-01"), + getdate("2021-02-01"), ) + + def test_subscription_invoice_generation_before_days(self): + subscription = create_subscription( + start_date="2023-01-01", + generate_invoice_at="Days before the current subscription period", + number_of_days=10, + generate_new_invoices_past_due_date=1, + ) + + subscription.process(posting_date="2022-12-22") + self.assertEqual(len(subscription.invoices), 1) + + subscription.process(posting_date="2023-01-22") + self.assertEqual(len(subscription.invoices), 2) + + +def make_plans(): + create_plan(plan_name="_Test Plan Name", cost=900) + create_plan(plan_name="_Test Plan Name 2", cost=1999) + create_plan( + plan_name="_Test Plan Name 3", cost=1999, billing_interval="Day", billing_interval_count=14 + ) + create_plan( + plan_name="_Test Plan Name 4", cost=20000, billing_interval="Month", billing_interval_count=3 + ) + create_plan( + plan_name="_Test Plan Multicurrency", cost=50, billing_interval="Month", currency="USD" + ) + + +def create_plan(**kwargs): + if not frappe.db.exists("Subscription Plan", kwargs.get("plan_name")): + plan = frappe.new_doc("Subscription Plan") + plan.plan_name = kwargs.get("plan_name") or "_Test Plan Name" + plan.item = kwargs.get("item") or "_Test Non Stock Item" + plan.price_determination = kwargs.get("price_determination") or "Fixed Rate" + plan.cost = kwargs.get("cost") or 1000 + plan.billing_interval = kwargs.get("billing_interval") or "Month" + plan.billing_interval_count = kwargs.get("billing_interval_count") or 1 + plan.currency = kwargs.get("currency") + plan.insert() + + +def create_parties(): + if not frappe.db.exists("Supplier", "_Test Supplier"): + supplier = frappe.new_doc("Supplier") + supplier.supplier_name = "_Test Supplier" + supplier.supplier_group = "All Supplier Groups" + supplier.insert() + + if not frappe.db.exists("Customer", "_Test Subscription Customer"): + customer = frappe.new_doc("Customer") + customer.customer_name = "_Test Subscription Customer" + customer.billing_currency = "USD" + customer.append( + "accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"} + ) + customer.insert() + + +def reset_settings(): + settings = frappe.get_single("Subscription Settings") + settings.grace_period = 0 + settings.cancel_after_grace = 0 + settings.save() + + +def create_subscription(**kwargs): + subscription = frappe.new_doc("Subscription") + subscription.party_type = (kwargs.get("party_type") or "Customer",) + subscription.company = kwargs.get("company") or "_Test Company" + subscription.party = kwargs.get("party") or "_Test Customer" + subscription.trial_period_start = kwargs.get("trial_period_start") + subscription.trial_period_end = kwargs.get("trial_period_end") + subscription.start_date = kwargs.get("start_date") + subscription.generate_invoice_at = kwargs.get("generate_invoice_at") + subscription.additional_discount_percentage = kwargs.get("additional_discount_percentage") + subscription.additional_discount_amount = kwargs.get("additional_discount_amount") + subscription.follow_calendar_months = kwargs.get("follow_calendar_months") + subscription.generate_new_invoices_past_due_date = kwargs.get( + "generate_new_invoices_past_due_date" + ) + subscription.submit_invoice = kwargs.get("submit_invoice") + subscription.days_until_due = kwargs.get("days_until_due") + subscription.number_of_days = kwargs.get("number_of_days") + + if not kwargs.get("plans"): + subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) + else: + for plan in kwargs.get("plans"): + subscription.append("plans", plan) + + if kwargs.get("do_not_save"): + return subscription + + subscription.save() + + return subscription diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 7bf8fb4492..2155699a4c 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -430,7 +430,7 @@ scheduler_events = { "erpnext.projects.doctype.project.project.collect_project_status", ], "hourly_long": [ - "erpnext.accounts.doctype.subscription.subscription.process_all", + "erpnext.accounts.doctype.process_subscription.process_subscription.create_subscription_process", "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries", "erpnext.bulk_transaction.doctype.bulk_transaction_log.bulk_transaction_log.retry_failing_transaction", ], diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 3433e3c5e6..e9c056e3a9 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -342,6 +342,7 @@ execute:frappe.db.set_single_value('Selling Settings', 'allow_negative_rates_for erpnext.patches.v15_0.correct_asset_value_if_je_with_workflow erpnext.patches.v15_0.delete_woocommerce_settings_doctype erpnext.patches.v14_0.migrate_deferred_accounts_to_item_defaults +erpnext.patches.v14_0.update_invoicing_period_in_subscription execute:frappe.delete_doc("Page", "welcome-to-erpnext") # below migration patch should always run last erpnext.patches.v14_0.migrate_gl_to_payment_ledger diff --git a/erpnext/patches/v14_0/update_invoicing_period_in_subscription.py b/erpnext/patches/v14_0/update_invoicing_period_in_subscription.py new file mode 100644 index 0000000000..2879e57e1a --- /dev/null +++ b/erpnext/patches/v14_0/update_invoicing_period_in_subscription.py @@ -0,0 +1,8 @@ +import frappe + + +def execute(): + subscription = frappe.qb.DocType("Subscription") + frappe.qb.update(subscription).set( + subscription.generate_invoice_at, "Beginning of the currency subscription period" + ).where(subscription.generate_invoice_at_period_start == 1).run()