feat: Add a process document for Subscription (#37126)
* feat: Add a process document for Subscription
* chore: Remove print statements
* feat: Input for generating invoice before currenc invoice date
* chore: patch for backward compatability
* refactor: Unit tests for subscription
* chore: set status on insert
diff --git a/erpnext/accounts/doctype/process_subscription/__init__.py b/erpnext/accounts/doctype/process_subscription/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/accounts/doctype/process_subscription/__init__.py
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 0000000..858c913
--- /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 0000000..502d002
--- /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 0000000..99269d6
--- /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 0000000..723695f
--- /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 c15aa1e..187b7ab 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
},
{
@@ -108,12 +111,6 @@
"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",
"fieldtype": "Section Break",
@@ -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 bbcade1..3cf7d28 100644
--- a/erpnext/accounts/doctype/subscription/subscription.py
+++ b/erpnext/accounts/doctype/subscription/subscription.py
@@ -36,12 +36,15 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 0bb171f..803e879 100644
--- a/erpnext/accounts/doctype/subscription/test_subscription.py
+++ b/erpnext/accounts/doctype/subscription/test_subscription.py
@@ -8,6 +8,7 @@
add_days,
add_months,
add_to_date,
+ cint,
date_diff,
flt,
get_date_str,
@@ -20,99 +21,16 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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()
+ subscription = create_subscription(start_date=add_days(nowdate(), -1000))
- frappe.flags.current_date = subscription.current_invoice_end
- subscription.process() # generate first invoice
-
+ subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
self.assertEqual(subscription.status, "Past Due Date")
subscription.process()
@@ -301,12 +155,7 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 7bf8fb4..2155699 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -430,7 +430,7 @@
"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 3433e3c..e9c056e 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -342,6 +342,7 @@
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 0000000..2879e57
--- /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()