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
This commit is contained in:
Deepesh Garg 2023-09-19 18:39:44 +05:30 committed by GitHub
parent 03f0abf6de
commit e19e04b050
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 406 additions and 364 deletions

View File

@ -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) {
// },
// });

View File

@ -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": []
}

View File

@ -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()

View File

@ -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

View File

@ -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",

View File

@ -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()

View File

@ -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

View File

@ -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",
],

View File

@ -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

View File

@ -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()