feat: subscription refactor (#30963)

* feat: subscription refactor

* fix: linter changes

* chore: linter changes

* chore: linter changes

* chore: Update tests

* chore: Remove commits

---------

Co-authored-by: Deepesh Garg <deepeshgarg6@gmail.com>
This commit is contained in:
Himanshu
2023-08-07 08:33:47 +05:30
committed by GitHub
parent b717e2b5bf
commit 38805603db
8 changed files with 438 additions and 466 deletions

View File

@@ -167,6 +167,7 @@
"column_break_63", "column_break_63",
"unrealized_profit_loss_account", "unrealized_profit_loss_account",
"subscription_section", "subscription_section",
"subscription",
"auto_repeat", "auto_repeat",
"update_auto_repeat_reference", "update_auto_repeat_reference",
"column_break_114", "column_break_114",
@@ -1423,6 +1424,12 @@
"options": "Advance Tax", "options": "Advance Tax",
"read_only": 1 "read_only": 1
}, },
{
"fieldname": "subscription",
"fieldtype": "Link",
"label": "Subscription",
"options": "Subscription"
},
{ {
"default": "0", "default": "0",
"fieldname": "is_old_subcontracting_flow", "fieldname": "is_old_subcontracting_flow",
@@ -1577,7 +1584,7 @@
"idx": 204, "idx": 204,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2023-07-04 17:22:59.145031", "modified": "2023-07-25 17:22:59.145031",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Purchase Invoice", "name": "Purchase Invoice",

View File

@@ -194,6 +194,7 @@
"select_print_heading", "select_print_heading",
"language", "language",
"subscription_section", "subscription_section",
"subscription",
"from_date", "from_date",
"auto_repeat", "auto_repeat",
"column_break_140", "column_break_140",
@@ -2017,6 +2018,12 @@
"label": "Amount Eligible for Commission", "label": "Amount Eligible for Commission",
"read_only": 1 "read_only": 1
}, },
{
"fieldname": "subscription",
"fieldtype": "Link",
"label": "Subscription",
"options": "Subscription"
},
{ {
"default": "0", "default": "0",
"depends_on": "eval: doc.apply_discount_on == \"Grand Total\"", "depends_on": "eval: doc.apply_discount_on == \"Grand Total\"",
@@ -2157,7 +2164,7 @@
"link_fieldname": "consolidated_invoice" "link_fieldname": "consolidated_invoice"
} }
], ],
"modified": "2023-06-21 16:02:18.988799", "modified": "2023-07-25 16:02:18.988799",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Sales Invoice", "name": "Sales Invoice",

View File

@@ -2,16 +2,16 @@
// For license information, please see license.txt // For license information, please see license.txt
frappe.ui.form.on('Subscription', { frappe.ui.form.on('Subscription', {
setup: function(frm) { setup: function (frm) {
frm.set_query('party_type', function() { frm.set_query('party_type', function () {
return { return {
filters : { filters: {
name: ['in', ['Customer', 'Supplier']] name: ['in', ['Customer', 'Supplier']]
} }
} }
}); });
frm.set_query('cost_center', function() { frm.set_query('cost_center', function () {
return { return {
filters: { filters: {
company: frm.doc.company company: frm.doc.company
@@ -20,76 +20,60 @@ frappe.ui.form.on('Subscription', {
}); });
}, },
refresh: function(frm) { refresh: function (frm) {
if(!frm.is_new()){ if (frm.is_new()) return;
if(frm.doc.status !== 'Cancelled'){
frm.add_custom_button( if (frm.doc.status !== 'Cancelled') {
__('Cancel Subscription'), frm.add_custom_button(
() => frm.events.cancel_this_subscription(frm) __('Fetch Subscription Updates'),
); () => frm.trigger('get_subscription_updates'),
frm.add_custom_button( __('Actions')
__('Fetch Subscription Updates'), );
() => frm.events.get_subscription_updates(frm)
); frm.add_custom_button(
} __('Cancel Subscription'),
else if(frm.doc.status === 'Cancelled'){ () => frm.trigger('cancel_this_subscription'),
frm.add_custom_button( __('Actions')
__('Restart Subscription'), );
() => frm.events.renew_this_subscription(frm) } else if (frm.doc.status === 'Cancelled') {
); frm.add_custom_button(
} __('Restart Subscription'),
() => frm.trigger('renew_this_subscription'),
__('Actions')
);
} }
}, },
cancel_this_subscription: function(frm) { cancel_this_subscription: function (frm) {
const doc = frm.doc;
frappe.confirm( frappe.confirm(
__('This action will stop future billing. Are you sure you want to cancel this subscription?'), __('This action will stop future billing. Are you sure you want to cancel this subscription?'),
function() { () => {
frappe.call({ frm.call('cancel_subscription').then(r => {
method: if (!r.exec) {
"erpnext.accounts.doctype.subscription.subscription.cancel_subscription", frm.reload_doc();
args: {name: doc.name},
callback: function(data){
if(!data.exc){
frm.reload_doc();
}
} }
}); });
} }
); );
}, },
renew_this_subscription: function(frm) { renew_this_subscription: function (frm) {
const doc = frm.doc;
frappe.confirm( frappe.confirm(
__('You will lose records of previously generated invoices. Are you sure you want to restart this subscription?'), __('Are you sure you want to restart this subscription?'),
function() { () => {
frappe.call({ frm.call('restart_subscription').then(r => {
method: if (!r.exec) {
"erpnext.accounts.doctype.subscription.subscription.restart_subscription", frm.reload_doc();
args: {name: doc.name},
callback: function(data){
if(!data.exc){
frm.reload_doc();
}
} }
}); });
} }
); );
}, },
get_subscription_updates: function(frm) { get_subscription_updates: function (frm) {
const doc = frm.doc; frm.call('process').then(r => {
frappe.call({ if (!r.exec) {
method: frm.reload_doc();
"erpnext.accounts.doctype.subscription.subscription.get_subscription_updates",
args: {name: doc.name},
freeze: true,
callback: function(data){
if(!data.exc){
frm.reload_doc();
}
} }
}); });
} }

View File

@@ -19,6 +19,7 @@
"trial_period_end", "trial_period_end",
"follow_calendar_months", "follow_calendar_months",
"generate_new_invoices_past_due_date", "generate_new_invoices_past_due_date",
"submit_invoice",
"column_break_11", "column_break_11",
"current_invoice_start", "current_invoice_start",
"current_invoice_end", "current_invoice_end",
@@ -35,12 +36,8 @@
"cb_2", "cb_2",
"additional_discount_percentage", "additional_discount_percentage",
"additional_discount_amount", "additional_discount_amount",
"sb_3",
"submit_invoice",
"invoices",
"accounting_dimensions_section", "accounting_dimensions_section",
"cost_center", "cost_center"
"dimension_col_break"
], ],
"fields": [ "fields": [
{ {
@@ -162,29 +159,12 @@
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Additional DIscount Amount" "label": "Additional DIscount Amount"
}, },
{
"depends_on": "eval:doc.invoices",
"fieldname": "sb_3",
"fieldtype": "Section Break",
"label": "Invoices"
},
{
"collapsible": 1,
"fieldname": "invoices",
"fieldtype": "Table",
"label": "Invoices",
"options": "Subscription Invoice"
},
{ {
"collapsible": 1, "collapsible": 1,
"fieldname": "accounting_dimensions_section", "fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Accounting Dimensions" "label": "Accounting Dimensions"
}, },
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
},
{ {
"fieldname": "party_type", "fieldname": "party_type",
"fieldtype": "Link", "fieldtype": "Link",
@@ -259,15 +239,27 @@
"default": "1", "default": "1",
"fieldname": "submit_invoice", "fieldname": "submit_invoice",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Submit Invoice Automatically" "label": "Submit Generated Invoices"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [
"modified": "2021-04-19 15:24:27.550797", {
"group": "Buying",
"link_doctype": "Purchase Invoice",
"link_fieldname": "subscription"
},
{
"group": "Selling",
"link_doctype": "Sales Invoice",
"link_fieldname": "subscription"
}
],
"modified": "2022-02-18 23:24:57.185054",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Subscription", "name": "Subscription",
"naming_rule": "Expression (old style)",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
@@ -309,5 +301,6 @@
], ],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@@ -2,14 +2,17 @@
# For license information, please see license.txt # For license information, please see license.txt
from datetime import datetime
from typing import Dict, List, Optional, Union
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils.data import ( from frappe.utils.data import (
add_days, add_days,
add_months,
add_to_date, add_to_date,
cint, cint,
cstr,
date_diff, date_diff,
flt, flt,
get_last_day, get_last_day,
@@ -17,8 +20,7 @@ from frappe.utils.data import (
nowdate, nowdate,
) )
import erpnext from erpnext import get_default_company, get_default_cost_center
from erpnext import get_default_company
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions, get_accounting_dimensions,
) )
@@ -26,33 +28,39 @@ from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_pla
from erpnext.accounts.party import get_party_account_currency from erpnext.accounts.party import get_party_account_currency
class InvoiceCancelled(frappe.ValidationError):
pass
class InvoiceNotCancelled(frappe.ValidationError):
pass
class Subscription(Document): class Subscription(Document):
def before_insert(self): def before_insert(self):
# update start just before the subscription doc is created # update start just before the subscription doc is created
self.update_subscription_period(self.start_date) self.update_subscription_period(self.start_date)
def update_subscription_period(self, date=None, return_date=False): def update_subscription_period(self, date: Optional[Union[datetime.date, str]] = None):
""" """
Subscription period is the period to be billed. This method updates the Subscription period is the period to be billed. This method updates the
beginning of the billing period and end of the billing period. beginning of the billing period and end of the billing period.
The beginning of the billing period is represented in the doctype as The beginning of the billing period is represented in the doctype as
`current_invoice_start` and the end of the billing period is represented `current_invoice_start` and the end of the billing period is represented
as `current_invoice_end`. as `current_invoice_end`.
If return_date is True, it wont update the start and end dates.
This is implemented to get the dates to check if is_current_invoice_generated
""" """
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):
_current_invoice_start = self.get_current_invoice_start(date) _current_invoice_start = self.get_current_invoice_start(date)
_current_invoice_end = self.get_current_invoice_end(_current_invoice_start) _current_invoice_end = self.get_current_invoice_end(_current_invoice_start)
if return_date: return _current_invoice_start, _current_invoice_end
return _current_invoice_start, _current_invoice_end
self.current_invoice_start = _current_invoice_start def get_current_invoice_start(
self.current_invoice_end = _current_invoice_end self, date: Optional[Union[datetime.date, str]] = None
) -> Union[datetime.date, str]:
def get_current_invoice_start(self, date=None):
""" """
This returns the date of the beginning of the current billing period. This returns the date of the beginning of the current billing period.
If the `date` parameter is not given , it will be automatically set as today's If the `date` parameter is not given , it will be automatically set as today's
@@ -75,13 +83,13 @@ class Subscription(Document):
return _current_invoice_start return _current_invoice_start
def get_current_invoice_end(self, date=None): def get_current_invoice_end(
self, date: Optional[Union[datetime.date, str]] = None
) -> Union[datetime.date, str]:
""" """
This returns the date of the end of the current billing period. This returns the date of the end of the current billing period.
If the subscription is in trial period, it will be set as the end of the If the subscription is in trial period, it will be set as the end of the
trial period. trial period.
If is not in a trial period, it will be `x` days from the beginning of the If is not in a trial period, it will be `x` days from the beginning of the
current billing period where `x` is the billing interval from the current billing period where `x` is the billing interval from the
`Subscription Plan` in the `Subscription`. `Subscription Plan` in the `Subscription`.
@@ -105,24 +113,13 @@ class Subscription(Document):
_current_invoice_end = get_last_day(date) _current_invoice_end = get_last_day(date)
if self.follow_calendar_months: if self.follow_calendar_months:
# Sets the end date
# eg if date is 17-Feb-2022, the invoice will be generated per month ie
# the invoice will be created from 17 Feb to 28 Feb
billing_info = self.get_billing_cycle_and_interval() billing_info = self.get_billing_cycle_and_interval()
billing_interval_count = billing_info[0]["billing_interval_count"] billing_interval_count = billing_info[0]["billing_interval_count"]
calendar_months = get_calendar_months(billing_interval_count) _end = add_months(getdate(date), billing_interval_count - 1)
calendar_month = 0 _current_invoice_end = get_last_day(_end)
current_invoice_end_month = getdate(_current_invoice_end).month
current_invoice_end_year = getdate(_current_invoice_end).year
for month in calendar_months:
if month <= current_invoice_end_month:
calendar_month = month
if cint(calendar_month - billing_interval_count) <= 0 and getdate(date).month != 1:
calendar_month = 12
current_invoice_end_year -= 1
_current_invoice_end = get_last_day(
cstr(current_invoice_end_year) + "-" + cstr(calendar_month) + "-01"
)
if self.end_date and getdate(_current_invoice_end) > getdate(self.end_date): if self.end_date and getdate(_current_invoice_end) > getdate(self.end_date):
_current_invoice_end = self.end_date _current_invoice_end = self.end_date
@@ -130,7 +127,7 @@ class Subscription(Document):
return _current_invoice_end return _current_invoice_end
@staticmethod @staticmethod
def validate_plans_billing_cycle(billing_cycle_data): def validate_plans_billing_cycle(billing_cycle_data: List[Dict[str, str]]) -> None:
""" """
Makes sure that all `Subscription Plan` in the `Subscription` have the Makes sure that all `Subscription Plan` in the `Subscription` have the
same billing interval same billing interval
@@ -138,10 +135,9 @@ class Subscription(Document):
if billing_cycle_data and len(billing_cycle_data) != 1: if billing_cycle_data and len(billing_cycle_data) != 1:
frappe.throw(_("You can only have Plans with the same billing cycle in a Subscription")) frappe.throw(_("You can only have Plans with the same billing cycle in a Subscription"))
def get_billing_cycle_and_interval(self): def get_billing_cycle_and_interval(self) -> List[Dict[str, str]]:
""" """
Returns a dict representing the billing interval and cycle for this `Subscription`. Returns a dict representing the billing interval and cycle for this `Subscription`.
You shouldn't need to call this directly. Use `get_billing_cycle` instead. You shouldn't need to call this directly. Use `get_billing_cycle` instead.
""" """
plan_names = [plan.plan for plan in self.plans] plan_names = [plan.plan for plan in self.plans]
@@ -156,72 +152,65 @@ class Subscription(Document):
return billing_info return billing_info
def get_billing_cycle_data(self): def get_billing_cycle_data(self) -> Dict[str, int]:
""" """
Returns dict contain the billing cycle data. Returns dict contain the billing cycle data.
You shouldn't need to call this directly. Use `get_billing_cycle` instead. You shouldn't need to call this directly. Use `get_billing_cycle` instead.
""" """
billing_info = self.get_billing_cycle_and_interval() billing_info = self.get_billing_cycle_and_interval()
if not billing_info:
return None
self.validate_plans_billing_cycle(billing_info) data = dict()
interval = billing_info[0]["billing_interval"]
interval_count = billing_info[0]["billing_interval_count"]
if billing_info: if interval not in ["Day", "Week"]:
data = dict() data["days"] = -1
interval = billing_info[0]["billing_interval"]
interval_count = billing_info[0]["billing_interval_count"]
if interval not in ["Day", "Week"]:
data["days"] = -1
if interval == "Day":
data["days"] = interval_count - 1
elif interval == "Month":
data["months"] = interval_count
elif interval == "Year":
data["years"] = interval_count
# todo: test week
elif interval == "Week":
data["days"] = interval_count * 7 - 1
return data if interval == "Day":
data["days"] = interval_count - 1
elif interval == "Week":
data["days"] = interval_count * 7 - 1
elif interval == "Month":
data["months"] = interval_count
elif interval == "Year":
data["years"] = interval_count
def set_status_grace_period(self): return data
"""
Sets the `Subscription` `status` based on the preference set in `Subscription Settings`.
Used when the `Subscription` needs to decide what to do after the current generated def set_subscription_status(self) -> None:
invoice is past it's due date and grace period.
"""
subscription_settings = frappe.get_single("Subscription Settings")
if self.status == "Past Due Date" and self.is_past_grace_period():
self.status = "Cancelled" if cint(subscription_settings.cancel_after_grace) else "Unpaid"
def set_subscription_status(self):
""" """
Sets the status of the `Subscription` Sets the status of the `Subscription`
""" """
if self.is_trialling(): if self.is_trialling():
self.status = "Trialling" self.status = "Trialling"
elif self.status == "Active" and self.end_date and getdate() > getdate(self.end_date): elif (
self.status == "Active"
and self.end_date
and getdate(frappe.flags.current_date) > getdate(self.end_date)
):
self.status = "Completed" self.status = "Completed"
elif self.is_past_grace_period(): elif self.is_past_grace_period():
subscription_settings = frappe.get_single("Subscription Settings") self.status = self.get_status_for_past_grace_period()
self.status = "Cancelled" if cint(subscription_settings.cancel_after_grace) else "Unpaid" self.cancelation_date = (
getdate(frappe.flags.current_date) if self.status == "Cancelled" else None
)
elif self.current_invoice_is_past_due() and not self.is_past_grace_period(): elif self.current_invoice_is_past_due() and not self.is_past_grace_period():
self.status = "Past Due Date" self.status = "Past Due Date"
elif not self.has_outstanding_invoice(): elif not self.has_outstanding_invoice() or self.is_new_subscription():
self.status = "Active"
elif self.is_new_subscription():
self.status = "Active" self.status = "Active"
self.save() self.save()
def is_trialling(self): def is_trialling(self) -> bool:
""" """
Returns `True` if the `Subscription` is in trial period. Returns `True` if the `Subscription` is in trial period.
""" """
return not self.period_has_passed(self.trial_period_end) and self.is_new_subscription() return not self.period_has_passed(self.trial_period_end) and self.is_new_subscription()
@staticmethod @staticmethod
def period_has_passed(end_date): def period_has_passed(end_date: Union[str, datetime.date]) -> bool:
""" """
Returns true if the given `end_date` has passed Returns true if the given `end_date` has passed
""" """
@@ -229,61 +218,59 @@ class Subscription(Document):
if not end_date: if not end_date:
return True return True
end_date = getdate(end_date) return getdate(frappe.flags.current_date) > getdate(end_date)
return getdate() > getdate(end_date)
def is_past_grace_period(self): def get_status_for_past_grace_period(self) -> str:
cancel_after_grace = cint(frappe.get_value("Subscription Settings", None, "cancel_after_grace"))
status = "Unpaid"
if cancel_after_grace:
status = "Cancelled"
return status
def is_past_grace_period(self) -> bool:
""" """
Returns `True` if the grace period for the `Subscription` has passed Returns `True` if the grace period for the `Subscription` has passed
""" """
current_invoice = self.get_current_invoice() if not self.current_invoice_is_past_due():
if self.current_invoice_is_past_due(current_invoice): return
subscription_settings = frappe.get_single("Subscription Settings")
grace_period = cint(subscription_settings.grace_period)
return getdate() > add_days(current_invoice.due_date, grace_period) 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)
)
def current_invoice_is_past_due(self, current_invoice=None): def current_invoice_is_past_due(self) -> bool:
""" """
Returns `True` if the current generated invoice is overdue Returns `True` if the current generated invoice is overdue
""" """
if not current_invoice: if not self.current_invoice or self.is_paid(self.current_invoice):
current_invoice = self.get_current_invoice()
if not current_invoice or self.is_paid(current_invoice):
return False return False
else:
return getdate() > getdate(current_invoice.due_date)
def get_current_invoice(self): return getdate(frappe.flags.current_date) >= getdate(self.current_invoice.due_date)
"""
Returns the most recent generated invoice.
"""
doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
if len(self.invoices): @property
current = self.invoices[-1] def invoice_document_type(self) -> str:
if frappe.db.exists(doctype, current.get("invoice")): return "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
doc = frappe.get_doc(doctype, current.get("invoice"))
return doc
else:
frappe.throw(_("Invoice {0} no longer exists").format(current.get("invoice")))
def is_new_subscription(self): def is_new_subscription(self) -> bool:
""" """
Returns `True` if `Subscription` has never generated an invoice Returns `True` if `Subscription` has never generated an invoice
""" """
return len(self.invoices) == 0 return self.is_new() or not frappe.db.exists(
{"doctype": self.invoice_document_type, "subscription": self.name}
)
def validate(self): def validate(self) -> None:
self.validate_trial_period() self.validate_trial_period()
self.validate_plans_billing_cycle(self.get_billing_cycle_and_interval()) self.validate_plans_billing_cycle(self.get_billing_cycle_and_interval())
self.validate_end_date() self.validate_end_date()
self.validate_to_follow_calendar_months() self.validate_to_follow_calendar_months()
if not self.cost_center: if not self.cost_center:
self.cost_center = erpnext.get_default_cost_center(self.get("company")) self.cost_center = get_default_cost_center(self.get("company"))
def validate_trial_period(self): def validate_trial_period(self) -> None:
""" """
Runs sanity checks on trial period dates for the `Subscription` Runs sanity checks on trial period dates for the `Subscription`
""" """
@@ -297,7 +284,7 @@ class Subscription(Document):
if self.trial_period_start and getdate(self.trial_period_start) > getdate(self.start_date): if self.trial_period_start and getdate(self.trial_period_start) > getdate(self.start_date):
frappe.throw(_("Trial Period Start date cannot be after Subscription Start Date")) frappe.throw(_("Trial Period Start date cannot be after Subscription Start Date"))
def validate_end_date(self): def validate_end_date(self) -> None:
billing_cycle_info = self.get_billing_cycle_data() billing_cycle_info = self.get_billing_cycle_data()
end_date = add_to_date(self.start_date, **billing_cycle_info) end_date = add_to_date(self.start_date, **billing_cycle_info)
@@ -306,53 +293,53 @@ class Subscription(Document):
_("Subscription End Date must be after {0} as per the subscription plan").format(end_date) _("Subscription End Date must be after {0} as per the subscription plan").format(end_date)
) )
def validate_to_follow_calendar_months(self): def validate_to_follow_calendar_months(self) -> None:
if self.follow_calendar_months: if not self.follow_calendar_months:
billing_info = self.get_billing_cycle_and_interval() return
if not self.end_date: billing_info = self.get_billing_cycle_and_interval()
frappe.throw(_("Subscription End Date is mandatory to follow calendar months"))
if billing_info[0]["billing_interval"] != "Month": if not self.end_date:
frappe.throw( frappe.throw(_("Subscription End Date is mandatory to follow calendar months"))
_("Billing Interval in Subscription Plan must be Month to follow calendar months")
)
def after_insert(self): 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? # todo: deal with users who collect prepayments. Maybe a new Subscription Invoice doctype?
self.set_subscription_status() self.set_subscription_status()
def generate_invoice(self, prorate=0): def generate_invoice(
self,
from_date: Optional[Union[str, datetime.date]] = None,
to_date: Optional[Union[str, datetime.date]] = None,
) -> Document:
""" """
Creates a `Invoice` for the `Subscription`, updates `self.invoices` and Creates a `Invoice` for the `Subscription`, updates `self.invoices` and
saves the `Subscription`. saves the `Subscription`.
Backwards compatibility
""" """
return self.create_invoice(from_date=from_date, to_date=to_date)
doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice" def create_invoice(
self,
invoice = self.create_invoice(prorate) from_date: Optional[Union[str, datetime.date]] = None,
self.append("invoices", {"document_type": doctype, "invoice": invoice.name}) to_date: Optional[Union[str, datetime.date]] = None,
) -> Document:
self.save()
return invoice
def create_invoice(self, prorate):
""" """
Creates a `Invoice`, submits it and returns it Creates a `Invoice`, submits it and returns it
""" """
doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
invoice = frappe.new_doc(doctype)
# For backward compatibility # For backward compatibility
# Earlier subscription didn't had any company field # Earlier subscription didn't had any company field
company = self.get("company") or get_default_company() company = self.get("company") or get_default_company()
if not company: if not company:
# fmt: off
frappe.throw( frappe.throw(
_("Company is mandatory was generating invoice. Please set default company in Global Defaults") _("Company is mandatory was generating invoice. Please set default company in Global Defaults.")
) )
# fmt: on
invoice = frappe.new_doc(self.invoice_document_type)
invoice.company = company invoice.company = company
invoice.set_posting_time = 1 invoice.set_posting_time = 1
invoice.posting_date = ( invoice.posting_date = (
@@ -363,17 +350,17 @@ class Subscription(Document):
invoice.cost_center = self.cost_center invoice.cost_center = self.cost_center
if doctype == "Sales Invoice": if self.invoice_document_type == "Sales Invoice":
invoice.customer = self.party invoice.customer = self.party
else: else:
invoice.supplier = self.party invoice.supplier = self.party
if frappe.db.get_value("Supplier", self.party, "tax_withholding_category"): if frappe.db.get_value("Supplier", self.party, "tax_withholding_category"):
invoice.apply_tds = 1 invoice.apply_tds = 1
### Add party currency to invoice # Add party currency to invoice
invoice.currency = get_party_account_currency(self.party_type, self.party, self.company) invoice.currency = get_party_account_currency(self.party_type, self.party, self.company)
## Add dimensions in invoice for subscription: # Add dimensions in invoice for subscription:
accounting_dimensions = get_accounting_dimensions() accounting_dimensions = get_accounting_dimensions()
for dimension in accounting_dimensions: for dimension in accounting_dimensions:
@@ -382,7 +369,7 @@ class Subscription(Document):
# Subscription is better suited for service items. I won't update `update_stock` # Subscription is better suited for service items. I won't update `update_stock`
# for that reason # for that reason
items_list = self.get_items_from_plans(self.plans, prorate) items_list = self.get_items_from_plans(self.plans, is_prorate())
for item in items_list: for item in items_list:
item["cost_center"] = self.cost_center item["cost_center"] = self.cost_center
invoice.append("items", item) invoice.append("items", item)
@@ -390,9 +377,9 @@ class Subscription(Document):
# Taxes # Taxes
tax_template = "" tax_template = ""
if doctype == "Sales Invoice" and self.sales_tax_template: if self.invoice_document_type == "Sales Invoice" and self.sales_tax_template:
tax_template = self.sales_tax_template tax_template = self.sales_tax_template
if doctype == "Purchase Invoice" and self.purchase_tax_template: if self.invoice_document_type == "Purchase Invoice" and self.purchase_tax_template:
tax_template = self.purchase_tax_template tax_template = self.purchase_tax_template
if tax_template: if tax_template:
@@ -424,8 +411,9 @@ class Subscription(Document):
invoice.apply_discount_on = discount_on if discount_on else "Grand Total" invoice.apply_discount_on = discount_on if discount_on else "Grand Total"
# Subscription period # Subscription period
invoice.from_date = self.current_invoice_start invoice.subscription = self.name
invoice.to_date = self.current_invoice_end invoice.from_date = from_date or self.current_invoice_start
invoice.to_date = to_date or self.current_invoice_end
invoice.flags.ignore_mandatory = True invoice.flags.ignore_mandatory = True
@@ -437,13 +425,20 @@ class Subscription(Document):
return invoice return invoice
def get_items_from_plans(self, plans, prorate=0): def get_items_from_plans(
self, plans: List[Dict[str, str]], prorate: Optional[bool] = None
) -> List[Dict]:
""" """
Returns the `Item`s linked to `Subscription Plan` Returns the `Item`s linked to `Subscription Plan`
""" """
if prorate is None:
prorate = False
if prorate: if prorate:
prorate_factor = get_prorata_factor( prorate_factor = get_prorata_factor(
self.current_invoice_end, self.current_invoice_start, self.generate_invoice_at_period_start self.current_invoice_end,
self.current_invoice_start,
cint(self.generate_invoice_at_period_start),
) )
items = [] items = []
@@ -465,7 +460,11 @@ class Subscription(Document):
"item_code": item_code, "item_code": item_code,
"qty": plan.qty, "qty": plan.qty,
"rate": get_plan_rate( "rate": get_plan_rate(
plan.plan, plan.qty, party, self.current_invoice_start, self.current_invoice_end plan.plan,
plan.qty,
party,
self.current_invoice_start,
self.current_invoice_end,
), ),
"cost_center": plan_doc.cost_center, "cost_center": plan_doc.cost_center,
} }
@@ -503,254 +502,184 @@ class Subscription(Document):
return items return items
def process(self): @frappe.whitelist()
def process(self) -> bool:
""" """
To be called by task periodically. It checks the subscription and takes appropriate action 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: as need be. It calls either of these methods depending the `Subscription` status:
1. `process_for_active` 1. `process_for_active`
2. `process_for_past_due` 2. `process_for_past_due`
""" """
if self.status == "Active": if (
self.process_for_active() not self.is_current_invoice_generated(self.current_invoice_start, self.current_invoice_end)
elif self.status in ["Past Due Date", "Unpaid"]: and self.can_generate_new_invoice()
self.process_for_past_due_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)
):
self.cancel_subscription()
self.set_subscription_status() self.set_subscription_status()
self.save() self.save()
def is_postpaid_to_invoice(self): def can_generate_new_invoice(self) -> bool:
return getdate() > getdate(self.current_invoice_end) or ( if self.cancelation_date:
getdate() >= getdate(self.current_invoice_end) return False
and getdate(self.current_invoice_end) == getdate(self.current_invoice_start) elif self.generate_invoice_at_period_start and (
) getdate(frappe.flags.current_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
def is_prepaid_to_invoice(self): return True
if not self.generate_invoice_at_period_start: else:
return False return False
if self.is_new_subscription() and getdate() >= getdate(self.current_invoice_start): def is_current_invoice_generated(
return True self,
_current_start_date: Union[datetime.date, str] = None,
# Check invoice dates and make sure it doesn't have outstanding invoices _current_end_date: Union[datetime.date, str] = None,
return getdate() >= getdate(self.current_invoice_start) ) -> bool:
def is_current_invoice_generated(self, _current_start_date=None, _current_end_date=None):
invoice = self.get_current_invoice()
if not (_current_start_date and _current_end_date): if not (_current_start_date and _current_end_date):
_current_start_date, _current_end_date = self.update_subscription_period( _current_start_date, _current_end_date = self._get_subscription_period(
date=add_days(self.current_invoice_end, 1), return_date=True date=add_days(self.current_invoice_end, 1)
) )
if invoice and getdate(_current_start_date) <= getdate(invoice.posting_date) <= getdate( if self.current_invoice and getdate(_current_start_date) <= getdate(
_current_end_date self.current_invoice.posting_date
): ) <= getdate(_current_end_date):
return True return True
return False return False
def process_for_active(self): @property
def current_invoice(self) -> Union[Document, None]:
""" """
Called by `process` if the status of the `Subscription` is 'Active'. Adds property for accessing the current_invoice
The possible outcomes of this method are:
1. Generate a new invoice
2. Change the `Subscription` status to 'Past Due Date'
3. Change the `Subscription` status to 'Cancelled'
""" """
return self.get_current_invoice()
if not self.is_current_invoice_generated( def get_current_invoice(self) -> Union[Document, None]:
self.current_invoice_start, self.current_invoice_end """
) and (self.is_postpaid_to_invoice() or self.is_prepaid_to_invoice()): Returns the most recent generated invoice.
"""
invoice = frappe.get_all(
self.invoice_document_type,
{
"subscription": self.name,
},
limit=1,
order_by="to_date desc",
pluck="name",
)
prorate = frappe.db.get_single_value("Subscription Settings", "prorate") if invoice:
self.generate_invoice(prorate) return frappe.get_doc(self.invoice_document_type, invoice[0])
if getdate() > getdate(self.current_invoice_end) and self.is_prepaid_to_invoice(): def cancel_subscription_at_period_end(self) -> None:
self.update_subscription_period(add_days(self.current_invoice_end, 1))
if self.cancel_at_period_end and getdate() > getdate(self.current_invoice_end):
self.cancel_subscription_at_period_end()
def cancel_subscription_at_period_end(self):
""" """
Called when `Subscription.cancel_at_period_end` is truthy Called when `Subscription.cancel_at_period_end` is truthy
""" """
if self.end_date and getdate() < getdate(self.end_date):
return
self.status = "Cancelled" self.status = "Cancelled"
if not self.cancelation_date: self.cancelation_date = nowdate()
self.cancelation_date = nowdate()
def process_for_past_due_date(self): @property
""" def invoices(self) -> List[Dict]:
Called by `process` if the status of the `Subscription` is 'Past Due Date'. return frappe.get_all(
self.invoice_document_type,
The possible outcomes of this method are: filters={"subscription": self.name},
1. Change the `Subscription` status to 'Active' order_by="from_date asc",
2. Change the `Subscription` status to 'Cancelled' )
3. Change the `Subscription` status to 'Unpaid'
"""
current_invoice = self.get_current_invoice()
if not current_invoice:
frappe.throw(_("Current invoice {0} is missing").format(current_invoice.invoice))
else:
if not self.has_outstanding_invoice():
self.status = "Active"
else:
self.set_status_grace_period()
if getdate() > getdate(self.current_invoice_end):
self.update_subscription_period(add_days(self.current_invoice_end, 1))
# Generate invoices periodically even if current invoice are unpaid
if (
self.generate_new_invoices_past_due_date
and not self.is_current_invoice_generated(self.current_invoice_start, self.current_invoice_end)
and (self.is_postpaid_to_invoice() or self.is_prepaid_to_invoice())
):
prorate = frappe.db.get_single_value("Subscription Settings", "prorate")
self.generate_invoice(prorate)
@staticmethod @staticmethod
def is_paid(invoice): def is_paid(invoice: Document) -> bool:
""" """
Return `True` if the given invoice is paid Return `True` if the given invoice is paid
""" """
return invoice.status == "Paid" return invoice.status == "Paid"
def has_outstanding_invoice(self): def has_outstanding_invoice(self) -> int:
""" """
Returns `True` if the most recent invoice for the `Subscription` is not paid Returns `True` if the most recent invoice for the `Subscription` is not paid
""" """
doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice" return frappe.db.count(
current_invoice = self.get_current_invoice() self.invoice_document_type,
invoice_list = [d.invoice for d in self.invoices] {
"subscription": self.name,
outstanding_invoices = frappe.get_all( "status": ["!=", "Paid"],
doctype, fields=["name"], filters={"status": ("!=", "Paid"), "name": ("in", invoice_list)} },
) )
if outstanding_invoices: @frappe.whitelist()
return True def cancel_subscription(self) -> None:
else:
False
def cancel_subscription(self):
""" """
This sets the subscription as cancelled. It will stop invoices from being generated This sets the subscription as cancelled. It will stop invoices from being generated
but it will not affect already created invoices. but it will not affect already created invoices.
""" """
if self.status != "Cancelled": if self.status == "Cancelled":
to_generate_invoice = ( frappe.throw(_("subscription is already cancelled."), InvoiceCancelled)
True if self.status == "Active" and not self.generate_invoice_at_period_start else False
)
to_prorate = frappe.db.get_single_value("Subscription Settings", "prorate")
self.status = "Cancelled"
self.cancelation_date = nowdate()
if to_generate_invoice:
self.generate_invoice(prorate=to_prorate)
self.save()
def restart_subscription(self): to_generate_invoice = (
True if self.status == "Active" and not self.generate_invoice_at_period_start else False
)
self.status = "Cancelled"
self.cancelation_date = nowdate()
if to_generate_invoice:
self.generate_invoice(self.current_invoice_start, self.cancelation_date)
self.save()
@frappe.whitelist()
def restart_subscription(self) -> None:
""" """
This sets the subscription as active. The subscription will be made to be like a new 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 subscription and the `Subscription` will lose all the history of generated invoices
it has. it has.
""" """
if self.status == "Cancelled": if not self.status == "Cancelled":
self.status = "Active" frappe.throw(_("You cannot restart a Subscription that is not cancelled."), InvoiceNotCancelled)
self.db_set("start_date", nowdate())
self.update_subscription_period(nowdate())
self.invoices = []
self.save()
else:
frappe.throw(_("You cannot restart a Subscription that is not cancelled."))
def get_precision(self): self.status = "Active"
invoice = self.get_current_invoice() self.cancelation_date = None
if invoice: self.update_subscription_period(frappe.flags.current_date or nowdate())
return invoice.precision("grand_total") self.save()
def get_calendar_months(billing_interval): def is_prorate() -> int:
calendar_months = [] return cint(frappe.db.get_single_value("Subscription Settings", "prorate"))
start = 0
while start < 12:
start += billing_interval
calendar_months.append(start)
return calendar_months
def get_prorata_factor(period_end, period_start, is_prepaid): def get_prorata_factor(
period_end: Union[datetime.date, str],
period_start: Union[datetime.date, str],
is_prepaid: Optional[int] = None,
) -> Union[int, float]:
if is_prepaid: if is_prepaid:
prorate_factor = 1 return 1
else:
diff = flt(date_diff(nowdate(), period_start) + 1)
plan_days = flt(date_diff(period_end, period_start) + 1)
prorate_factor = diff / plan_days
return prorate_factor diff = flt(date_diff(nowdate(), period_start) + 1)
plan_days = flt(date_diff(period_end, period_start) + 1)
return diff / plan_days
def process_all(): def process_all() -> None:
""" """
Task to updates the status of all `Subscription` apart from those that are cancelled Task to updates the status of all `Subscription` apart from those that are cancelled
""" """
subscriptions = get_all_subscriptions() for subscription in frappe.get_all("Subscription", {"status": ("!=", "Cancelled")}, pluck="name"):
for subscription in subscriptions:
process(subscription)
def get_all_subscriptions():
"""
Returns all `Subscription` documents
"""
return frappe.db.get_all("Subscription", {"status": ("!=", "Cancelled")})
def process(data):
"""
Checks a `Subscription` and updates it status as necessary
"""
if data:
try: try:
subscription = frappe.get_doc("Subscription", data["name"]) subscription = frappe.get_doc("Subscription", subscription)
subscription.process() subscription.process()
frappe.db.commit() frappe.db.commit()
except frappe.ValidationError: except frappe.ValidationError:
frappe.db.rollback() frappe.db.rollback()
subscription.log_error("Subscription failed") subscription.log_error("Subscription failed")
@frappe.whitelist()
def cancel_subscription(name):
"""
Cancels a `Subscription`. This will stop the `Subscription` from further invoicing the
`Subscriber` but all already outstanding invoices will not be affected.
"""
subscription = frappe.get_doc("Subscription", name)
subscription.cancel_subscription()
@frappe.whitelist()
def restart_subscription(name):
"""
Restarts a cancelled `Subscription`. The `Subscription` will 'forget' the history of
all invoices it has generated
"""
subscription = frappe.get_doc("Subscription", name)
subscription.restart_subscription()
@frappe.whitelist()
def get_subscription_updates(name):
"""
Use this to get the latest state of the given `Subscription`
"""
subscription = frappe.get_doc("Subscription", name)
subscription.process()

View File

@@ -11,6 +11,7 @@ from frappe.utils.data import (
date_diff, date_diff,
flt, flt,
get_date_str, get_date_str,
getdate,
nowdate, nowdate,
) )
@@ -90,10 +91,18 @@ def create_parties():
customer.insert() 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): class TestSubscription(unittest.TestCase):
def setUp(self): def setUp(self):
create_plan() create_plan()
create_parties() create_parties()
reset_settings()
def test_create_subscription_with_trial_with_correct_period(self): def test_create_subscription_with_trial_with_correct_period(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
@@ -116,8 +125,6 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.invoices, []) self.assertEqual(subscription.invoices, [])
self.assertEqual(subscription.status, "Trialling") self.assertEqual(subscription.status, "Trialling")
subscription.delete()
def test_create_subscription_without_trial_with_correct_period(self): def test_create_subscription_without_trial_with_correct_period(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer" subscription.party_type = "Customer"
@@ -133,8 +140,6 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(len(subscription.invoices), 0) self.assertEqual(len(subscription.invoices), 0)
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
subscription.delete()
def test_create_subscription_trial_with_wrong_dates(self): def test_create_subscription_trial_with_wrong_dates(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer" subscription.party_type = "Customer"
@@ -144,7 +149,6 @@ class TestSubscription(unittest.TestCase):
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
self.assertRaises(frappe.ValidationError, subscription.save) self.assertRaises(frappe.ValidationError, subscription.save)
subscription.delete()
def test_create_subscription_multi_with_different_billing_fails(self): def test_create_subscription_multi_with_different_billing_fails(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
@@ -156,7 +160,6 @@ class TestSubscription(unittest.TestCase):
subscription.append("plans", {"plan": "_Test Plan Name 3", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name 3", "qty": 1})
self.assertRaises(frappe.ValidationError, subscription.save) self.assertRaises(frappe.ValidationError, subscription.save)
subscription.delete()
def test_invoice_is_generated_at_end_of_billing_period(self): def test_invoice_is_generated_at_end_of_billing_period(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
@@ -169,13 +172,13 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
self.assertEqual(subscription.current_invoice_start, "2018-01-01") self.assertEqual(subscription.current_invoice_start, "2018-01-01")
self.assertEqual(subscription.current_invoice_end, "2018-01-31") self.assertEqual(subscription.current_invoice_end, "2018-01-31")
frappe.flags.current_date = "2018-01-31"
subscription.process() subscription.process()
self.assertEqual(len(subscription.invoices), 1) self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.current_invoice_start, "2018-01-01") self.assertEqual(subscription.current_invoice_start, "2018-02-01")
subscription.process() self.assertEqual(subscription.current_invoice_end, "2018-02-28")
self.assertEqual(subscription.status, "Unpaid") self.assertEqual(subscription.status, "Unpaid")
subscription.delete()
def test_status_goes_back_to_active_after_invoice_is_paid(self): def test_status_goes_back_to_active_after_invoice_is_paid(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
@@ -183,7 +186,9 @@ class TestSubscription(unittest.TestCase):
subscription.party = "_Test Customer" subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.start_date = "2018-01-01" subscription.start_date = "2018-01-01"
subscription.generate_invoice_at_period_start = True
subscription.insert() subscription.insert()
frappe.flags.current_date = "2018-01-01"
subscription.process() # generate first invoice subscription.process() # generate first invoice
self.assertEqual(len(subscription.invoices), 1) self.assertEqual(len(subscription.invoices), 1)
@@ -203,11 +208,8 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.current_invoice_start, add_months(subscription.start_date, 1)) self.assertEqual(subscription.current_invoice_start, add_months(subscription.start_date, 1))
self.assertEqual(len(subscription.invoices), 1) self.assertEqual(len(subscription.invoices), 1)
subscription.delete()
def test_subscription_cancel_after_grace_period(self): def test_subscription_cancel_after_grace_period(self):
settings = frappe.get_single("Subscription Settings") settings = frappe.get_single("Subscription Settings")
default_grace_period_action = settings.cancel_after_grace
settings.cancel_after_grace = 1 settings.cancel_after_grace = 1
settings.save() settings.save()
@@ -215,20 +217,18 @@ class TestSubscription(unittest.TestCase):
subscription.party_type = "Customer" subscription.party_type = "Customer"
subscription.party = "_Test Customer" subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
# subscription.generate_invoice_at_period_start = True
subscription.start_date = "2018-01-01" subscription.start_date = "2018-01-01"
subscription.insert() subscription.insert()
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
frappe.flags.current_date = "2018-01-31"
subscription.process() # generate first invoice subscription.process() # generate first invoice
# This should change status to Cancelled since grace period is 0 # This should change status to Cancelled since grace period is 0
# And is backdated subscription so subscription will be cancelled after processing # And is backdated subscription so subscription will be cancelled after processing
self.assertEqual(subscription.status, "Cancelled") self.assertEqual(subscription.status, "Cancelled")
settings.cancel_after_grace = default_grace_period_action
settings.save()
subscription.delete()
def test_subscription_unpaid_after_grace_period(self): def test_subscription_unpaid_after_grace_period(self):
settings = frappe.get_single("Subscription Settings") settings = frappe.get_single("Subscription Settings")
default_grace_period_action = settings.cancel_after_grace default_grace_period_action = settings.cancel_after_grace
@@ -248,21 +248,26 @@ class TestSubscription(unittest.TestCase):
settings.cancel_after_grace = default_grace_period_action settings.cancel_after_grace = default_grace_period_action
settings.save() settings.save()
subscription.delete()
def test_subscription_invoice_days_until_due(self): def test_subscription_invoice_days_until_due(self):
_date = add_months(nowdate(), -1)
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer" subscription.party_type = "Customer"
subscription.party = "_Test Customer" subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.days_until_due = 10 subscription.days_until_due = 10
subscription.start_date = add_months(nowdate(), -1) subscription.start_date = _date
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.insert() subscription.insert()
frappe.flags.current_date = subscription.current_invoice_end
subscription.process() # generate first invoice subscription.process() # generate first invoice
self.assertEqual(len(subscription.invoices), 1) self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
subscription.delete() frappe.flags.current_date = add_days(subscription.current_invoice_end, 3)
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, "Active")
def test_subscription_is_past_due_doesnt_change_within_grace_period(self): def test_subscription_is_past_due_doesnt_change_within_grace_period(self):
settings = frappe.get_single("Subscription Settings") settings = frappe.get_single("Subscription Settings")
@@ -276,6 +281,8 @@ class TestSubscription(unittest.TestCase):
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.start_date = add_days(nowdate(), -1000) subscription.start_date = add_days(nowdate(), -1000)
subscription.insert() subscription.insert()
frappe.flags.current_date = subscription.current_invoice_end
subscription.process() # generate first invoice subscription.process() # generate first invoice
self.assertEqual(subscription.status, "Past Due Date") self.assertEqual(subscription.status, "Past Due Date")
@@ -292,7 +299,6 @@ class TestSubscription(unittest.TestCase):
settings.grace_period = grace_period settings.grace_period = grace_period
settings.save() settings.save()
subscription.delete()
def test_subscription_remains_active_during_invoice_period(self): def test_subscription_remains_active_during_invoice_period(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
@@ -319,8 +325,6 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1)) self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(len(subscription.invoices), 0) self.assertEqual(len(subscription.invoices), 0)
subscription.delete()
def test_subscription_cancelation(self): def test_subscription_cancelation(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer" subscription.party_type = "Customer"
@@ -331,8 +335,6 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Cancelled") self.assertEqual(subscription.status, "Cancelled")
subscription.delete()
def test_subscription_cancellation_invoices(self): def test_subscription_cancellation_invoices(self):
settings = frappe.get_single("Subscription Settings") settings = frappe.get_single("Subscription Settings")
to_prorate = settings.prorate to_prorate = settings.prorate
@@ -372,7 +374,6 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(flt(invoice.grand_total, 2), flt(prorate_factor * 900, 2)) self.assertEqual(flt(invoice.grand_total, 2), flt(prorate_factor * 900, 2))
self.assertEqual(subscription.status, "Cancelled") self.assertEqual(subscription.status, "Cancelled")
subscription.delete()
settings.prorate = to_prorate settings.prorate = to_prorate
settings.save() settings.save()
@@ -395,8 +396,6 @@ class TestSubscription(unittest.TestCase):
settings.prorate = to_prorate settings.prorate = to_prorate
settings.save() settings.save()
subscription.delete()
def test_subscription_cancellation_invoices_with_prorata_true(self): def test_subscription_cancellation_invoices_with_prorata_true(self):
settings = frappe.get_single("Subscription Settings") settings = frappe.get_single("Subscription Settings")
to_prorate = settings.prorate to_prorate = settings.prorate
@@ -422,8 +421,6 @@ class TestSubscription(unittest.TestCase):
settings.prorate = to_prorate settings.prorate = to_prorate
settings.save() settings.save()
subscription.delete()
def test_subcription_cancellation_and_process(self): def test_subcription_cancellation_and_process(self):
settings = frappe.get_single("Subscription Settings") settings = frappe.get_single("Subscription Settings")
default_grace_period_action = settings.cancel_after_grace default_grace_period_action = settings.cancel_after_grace
@@ -437,23 +434,22 @@ class TestSubscription(unittest.TestCase):
subscription.start_date = "2018-01-01" subscription.start_date = "2018-01-01"
subscription.insert() subscription.insert()
subscription.process() # generate first invoice subscription.process() # generate first invoice
invoices = len(subscription.invoices)
# Generate an invoice for the cancelled period
subscription.cancel_subscription() subscription.cancel_subscription()
self.assertEqual(subscription.status, "Cancelled") self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(len(subscription.invoices), invoices) self.assertEqual(len(subscription.invoices), 1)
subscription.process() subscription.process()
self.assertEqual(subscription.status, "Cancelled") self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(len(subscription.invoices), invoices) self.assertEqual(len(subscription.invoices), 1)
subscription.process() subscription.process()
self.assertEqual(subscription.status, "Cancelled") self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(len(subscription.invoices), invoices) self.assertEqual(len(subscription.invoices), 1)
settings.cancel_after_grace = default_grace_period_action settings.cancel_after_grace = default_grace_period_action
settings.save() settings.save()
subscription.delete()
def test_subscription_restart_and_process(self): def test_subscription_restart_and_process(self):
settings = frappe.get_single("Subscription Settings") settings = frappe.get_single("Subscription Settings")
@@ -468,6 +464,7 @@ class TestSubscription(unittest.TestCase):
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.start_date = "2018-01-01" subscription.start_date = "2018-01-01"
subscription.insert() subscription.insert()
frappe.flags.current_date = "2018-01-31"
subscription.process() # generate first invoice subscription.process() # generate first invoice
# Status is unpaid as Days until Due is zero and grace period is Zero # Status is unpaid as Days until Due is zero and grace period is Zero
@@ -478,19 +475,18 @@ class TestSubscription(unittest.TestCase):
subscription.restart_subscription() subscription.restart_subscription()
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
self.assertEqual(len(subscription.invoices), 0) self.assertEqual(len(subscription.invoices), 1)
subscription.process() subscription.process()
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Unpaid")
self.assertEqual(len(subscription.invoices), 0) self.assertEqual(len(subscription.invoices), 1)
subscription.process() subscription.process()
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Unpaid")
self.assertEqual(len(subscription.invoices), 0) self.assertEqual(len(subscription.invoices), 1)
settings.cancel_after_grace = default_grace_period_action settings.cancel_after_grace = default_grace_period_action
settings.save() settings.save()
subscription.delete()
def test_subscription_unpaid_back_to_active(self): def test_subscription_unpaid_back_to_active(self):
settings = frappe.get_single("Subscription Settings") settings = frappe.get_single("Subscription Settings")
@@ -503,8 +499,11 @@ class TestSubscription(unittest.TestCase):
subscription.party = "_Test Customer" subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.start_date = "2018-01-01" subscription.start_date = "2018-01-01"
subscription.generate_invoice_at_period_start = True
subscription.insert() subscription.insert()
frappe.flags.current_date = subscription.current_invoice_start
subscription.process() # generate first invoice subscription.process() # generate first invoice
# This should change status to Unpaid since grace period is 0 # This should change status to Unpaid since grace period is 0
self.assertEqual(subscription.status, "Unpaid") self.assertEqual(subscription.status, "Unpaid")
@@ -517,12 +516,12 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
# A new invoice is generated # A new invoice is generated
frappe.flags.current_date = subscription.current_invoice_start
subscription.process() subscription.process()
self.assertEqual(subscription.status, "Unpaid") self.assertEqual(subscription.status, "Unpaid")
settings.cancel_after_grace = default_grace_period_action settings.cancel_after_grace = default_grace_period_action
settings.save() settings.save()
subscription.delete()
def test_restart_active_subscription(self): def test_restart_active_subscription(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
@@ -533,8 +532,6 @@ class TestSubscription(unittest.TestCase):
self.assertRaises(frappe.ValidationError, subscription.restart_subscription) self.assertRaises(frappe.ValidationError, subscription.restart_subscription)
subscription.delete()
def test_subscription_invoice_discount_percentage(self): def test_subscription_invoice_discount_percentage(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer" subscription.party_type = "Customer"
@@ -549,8 +546,6 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(invoice.additional_discount_percentage, 10) self.assertEqual(invoice.additional_discount_percentage, 10)
self.assertEqual(invoice.apply_discount_on, "Grand Total") self.assertEqual(invoice.apply_discount_on, "Grand Total")
subscription.delete()
def test_subscription_invoice_discount_amount(self): def test_subscription_invoice_discount_amount(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer" subscription.party_type = "Customer"
@@ -565,8 +560,6 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(invoice.discount_amount, 11) self.assertEqual(invoice.discount_amount, 11)
self.assertEqual(invoice.apply_discount_on, "Grand Total") self.assertEqual(invoice.apply_discount_on, "Grand Total")
subscription.delete()
def test_prepaid_subscriptions(self): def test_prepaid_subscriptions(self):
# Create a non pre-billed subscription, processing should not create # Create a non pre-billed subscription, processing should not create
# invoices. # invoices.
@@ -614,8 +607,6 @@ class TestSubscription(unittest.TestCase):
settings.prorate = to_prorate settings.prorate = to_prorate
settings.save() settings.save()
subscription.delete()
def test_subscription_with_follow_calendar_months(self): def test_subscription_with_follow_calendar_months(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
subscription.party_type = "Supplier" subscription.party_type = "Supplier"
@@ -623,14 +614,14 @@ class TestSubscription(unittest.TestCase):
subscription.generate_invoice_at_period_start = 1 subscription.generate_invoice_at_period_start = 1
subscription.follow_calendar_months = 1 subscription.follow_calendar_months = 1
# select subscription start date as '2018-01-15' # select subscription start date as "2018-01-15"
subscription.start_date = "2018-01-15" subscription.start_date = "2018-01-15"
subscription.end_date = "2018-07-15" subscription.end_date = "2018-07-15"
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
subscription.save() subscription.save()
# even though subscription starts at '2018-01-15' and Billing interval is Month and count 3 # even though subscription starts at "2018-01-15" and Billing interval is Month and count 3
# First invoice will end at '2018-03-31' instead of '2018-04-14' # First invoice will end at "2018-03-31" instead of "2018-04-14"
self.assertEqual(get_date_str(subscription.current_invoice_end), "2018-03-31") self.assertEqual(get_date_str(subscription.current_invoice_end), "2018-03-31")
def test_subscription_generate_invoice_past_due(self): def test_subscription_generate_invoice_past_due(self):
@@ -639,11 +630,12 @@ class TestSubscription(unittest.TestCase):
subscription.party = "_Test Supplier" subscription.party = "_Test Supplier"
subscription.generate_invoice_at_period_start = 1 subscription.generate_invoice_at_period_start = 1
subscription.generate_new_invoices_past_due_date = 1 subscription.generate_new_invoices_past_due_date = 1
# select subscription start date as '2018-01-15' # select subscription start date as "2018-01-15"
subscription.start_date = "2018-01-01" subscription.start_date = "2018-01-01"
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
subscription.save() subscription.save()
frappe.flags.current_date = "2018-01-01"
# Process subscription and create first invoice # Process subscription and create first invoice
# Subscription status will be unpaid since due date has already passed # Subscription status will be unpaid since due date has already passed
subscription.process() subscription.process()
@@ -652,8 +644,8 @@ class TestSubscription(unittest.TestCase):
# Now the Subscription is unpaid # Now the Subscription is unpaid
# Even then new invoice should be created as we have enabled `generate_new_invoices_past_due_date` in # Even then new invoice should be created as we have enabled `generate_new_invoices_past_due_date` in
# subscription # subscription and the interval between the subscriptions is 3 months
frappe.flags.current_date = "2018-04-01"
subscription.process() subscription.process()
self.assertEqual(len(subscription.invoices), 2) self.assertEqual(len(subscription.invoices), 2)
@@ -662,7 +654,7 @@ class TestSubscription(unittest.TestCase):
subscription.party_type = "Supplier" subscription.party_type = "Supplier"
subscription.party = "_Test Supplier" subscription.party = "_Test Supplier"
subscription.generate_invoice_at_period_start = 1 subscription.generate_invoice_at_period_start = 1
# select subscription start date as '2018-01-15' # select subscription start date as "2018-01-15"
subscription.start_date = "2018-01-01" subscription.start_date = "2018-01-01"
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
subscription.save() subscription.save()
@@ -682,7 +674,7 @@ class TestSubscription(unittest.TestCase):
subscription.party = "_Test Subscription Customer" subscription.party = "_Test Subscription Customer"
subscription.generate_invoice_at_period_start = 1 subscription.generate_invoice_at_period_start = 1
subscription.company = "_Test Company" subscription.company = "_Test Company"
# select subscription start date as '2018-01-15' # select subscription start date as "2018-01-15"
subscription.start_date = "2018-01-01" subscription.start_date = "2018-01-01"
subscription.append("plans", {"plan": "_Test Plan Multicurrency", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Multicurrency", "qty": 1})
subscription.save() subscription.save()
@@ -692,5 +684,47 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Unpaid") self.assertEqual(subscription.status, "Unpaid")
# Check the currency of the created invoice # Check the currency of the created invoice
currency = frappe.db.get_value("Sales Invoice", subscription.invoices[0].invoice, "currency") currency = frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "currency")
self.assertEqual(currency, "USD") self.assertEqual(currency, "USD")
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()
# create invoices for the first two moths
frappe.flags.current_date = "2021-12-31"
subscription.process()
frappe.flags.current_date = "2022-01-31"
subscription.process()
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"),
)
self.assertEqual(
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")),
getdate("2022-01-01"),
)
# recreate most recent invoice
subscription.process()
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"),
)
self.assertEqual(
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")),
getdate("2022-01-01"),
)

View File

@@ -262,6 +262,7 @@ erpnext.patches.v14_0.update_reference_due_date_in_journal_entry
erpnext.patches.v15_0.saudi_depreciation_warning erpnext.patches.v15_0.saudi_depreciation_warning
erpnext.patches.v15_0.delete_saudi_doctypes erpnext.patches.v15_0.delete_saudi_doctypes
erpnext.patches.v14_0.show_loan_management_deprecation_warning erpnext.patches.v14_0.show_loan_management_deprecation_warning
erpnext.patches.v14_0.update_subscription_details
execute:frappe.rename_doc("Report", "TDS Payable Monthly", "Tax Withholding Details", force=True) execute:frappe.rename_doc("Report", "TDS Payable Monthly", "Tax Withholding Details", force=True)
[post_model_sync] [post_model_sync]

View File

@@ -0,0 +1,17 @@
import frappe
def execute():
subscription_invoices = frappe.get_all(
"Subscription Invoice", fields=["document_type", "invoice", "parent"]
)
for subscription_invoice in subscription_invoices:
frappe.db.set_value(
subscription_invoice.document_type,
subscription_invoice.invoice,
"Subscription",
subscription_invoice.parent,
)
frappe.delete_doc_if_exists("DocType", "Subscription Invoice")