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:
parent
b717e2b5bf
commit
38805603db
@ -167,6 +167,7 @@
|
||||
"column_break_63",
|
||||
"unrealized_profit_loss_account",
|
||||
"subscription_section",
|
||||
"subscription",
|
||||
"auto_repeat",
|
||||
"update_auto_repeat_reference",
|
||||
"column_break_114",
|
||||
@ -1423,6 +1424,12 @@
|
||||
"options": "Advance Tax",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "subscription",
|
||||
"fieldtype": "Link",
|
||||
"label": "Subscription",
|
||||
"options": "Subscription"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_old_subcontracting_flow",
|
||||
@ -1577,7 +1584,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-07-04 17:22:59.145031",
|
||||
"modified": "2023-07-25 17:22:59.145031",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"select_print_heading",
|
||||
"language",
|
||||
"subscription_section",
|
||||
"subscription",
|
||||
"from_date",
|
||||
"auto_repeat",
|
||||
"column_break_140",
|
||||
@ -2017,6 +2018,12 @@
|
||||
"label": "Amount Eligible for Commission",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "subscription",
|
||||
"fieldtype": "Link",
|
||||
"label": "Subscription",
|
||||
"options": "Subscription"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval: doc.apply_discount_on == \"Grand Total\"",
|
||||
@ -2157,7 +2164,7 @@
|
||||
"link_fieldname": "consolidated_invoice"
|
||||
}
|
||||
],
|
||||
"modified": "2023-06-21 16:02:18.988799",
|
||||
"modified": "2023-07-25 16:02:18.988799",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice",
|
||||
|
@ -2,16 +2,16 @@
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on('Subscription', {
|
||||
setup: function(frm) {
|
||||
frm.set_query('party_type', function() {
|
||||
setup: function (frm) {
|
||||
frm.set_query('party_type', function () {
|
||||
return {
|
||||
filters : {
|
||||
filters: {
|
||||
name: ['in', ['Customer', 'Supplier']]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
frm.set_query('cost_center', function() {
|
||||
frm.set_query('cost_center', function () {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company
|
||||
@ -20,76 +20,60 @@ frappe.ui.form.on('Subscription', {
|
||||
});
|
||||
},
|
||||
|
||||
refresh: function(frm) {
|
||||
if(!frm.is_new()){
|
||||
if(frm.doc.status !== 'Cancelled'){
|
||||
frm.add_custom_button(
|
||||
__('Cancel Subscription'),
|
||||
() => frm.events.cancel_this_subscription(frm)
|
||||
);
|
||||
frm.add_custom_button(
|
||||
__('Fetch Subscription Updates'),
|
||||
() => frm.events.get_subscription_updates(frm)
|
||||
);
|
||||
}
|
||||
else if(frm.doc.status === 'Cancelled'){
|
||||
frm.add_custom_button(
|
||||
__('Restart Subscription'),
|
||||
() => frm.events.renew_this_subscription(frm)
|
||||
);
|
||||
}
|
||||
refresh: function (frm) {
|
||||
if (frm.is_new()) return;
|
||||
|
||||
if (frm.doc.status !== 'Cancelled') {
|
||||
frm.add_custom_button(
|
||||
__('Fetch Subscription Updates'),
|
||||
() => frm.trigger('get_subscription_updates'),
|
||||
__('Actions')
|
||||
);
|
||||
|
||||
frm.add_custom_button(
|
||||
__('Cancel Subscription'),
|
||||
() => frm.trigger('cancel_this_subscription'),
|
||||
__('Actions')
|
||||
);
|
||||
} else if (frm.doc.status === 'Cancelled') {
|
||||
frm.add_custom_button(
|
||||
__('Restart Subscription'),
|
||||
() => frm.trigger('renew_this_subscription'),
|
||||
__('Actions')
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
cancel_this_subscription: function(frm) {
|
||||
const doc = frm.doc;
|
||||
cancel_this_subscription: function (frm) {
|
||||
frappe.confirm(
|
||||
__('This action will stop future billing. Are you sure you want to cancel this subscription?'),
|
||||
function() {
|
||||
frappe.call({
|
||||
method:
|
||||
"erpnext.accounts.doctype.subscription.subscription.cancel_subscription",
|
||||
args: {name: doc.name},
|
||||
callback: function(data){
|
||||
if(!data.exc){
|
||||
frm.reload_doc();
|
||||
}
|
||||
() => {
|
||||
frm.call('cancel_subscription').then(r => {
|
||||
if (!r.exec) {
|
||||
frm.reload_doc();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
renew_this_subscription: function(frm) {
|
||||
const doc = frm.doc;
|
||||
renew_this_subscription: function (frm) {
|
||||
frappe.confirm(
|
||||
__('You will lose records of previously generated invoices. Are you sure you want to restart this subscription?'),
|
||||
function() {
|
||||
frappe.call({
|
||||
method:
|
||||
"erpnext.accounts.doctype.subscription.subscription.restart_subscription",
|
||||
args: {name: doc.name},
|
||||
callback: function(data){
|
||||
if(!data.exc){
|
||||
frm.reload_doc();
|
||||
}
|
||||
__('Are you sure you want to restart this subscription?'),
|
||||
() => {
|
||||
frm.call('restart_subscription').then(r => {
|
||||
if (!r.exec) {
|
||||
frm.reload_doc();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
get_subscription_updates: function(frm) {
|
||||
const doc = frm.doc;
|
||||
frappe.call({
|
||||
method:
|
||||
"erpnext.accounts.doctype.subscription.subscription.get_subscription_updates",
|
||||
args: {name: doc.name},
|
||||
freeze: true,
|
||||
callback: function(data){
|
||||
if(!data.exc){
|
||||
frm.reload_doc();
|
||||
}
|
||||
get_subscription_updates: function (frm) {
|
||||
frm.call('process').then(r => {
|
||||
if (!r.exec) {
|
||||
frm.reload_doc();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -19,6 +19,7 @@
|
||||
"trial_period_end",
|
||||
"follow_calendar_months",
|
||||
"generate_new_invoices_past_due_date",
|
||||
"submit_invoice",
|
||||
"column_break_11",
|
||||
"current_invoice_start",
|
||||
"current_invoice_end",
|
||||
@ -35,12 +36,8 @@
|
||||
"cb_2",
|
||||
"additional_discount_percentage",
|
||||
"additional_discount_amount",
|
||||
"sb_3",
|
||||
"submit_invoice",
|
||||
"invoices",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
"dimension_col_break"
|
||||
"cost_center"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@ -162,29 +159,12 @@
|
||||
"fieldtype": "Currency",
|
||||
"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,
|
||||
"fieldname": "accounting_dimensions_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Accounting Dimensions"
|
||||
},
|
||||
{
|
||||
"fieldname": "dimension_col_break",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "party_type",
|
||||
"fieldtype": "Link",
|
||||
@ -259,15 +239,27 @@
|
||||
"default": "1",
|
||||
"fieldname": "submit_invoice",
|
||||
"fieldtype": "Check",
|
||||
"label": "Submit Invoice Automatically"
|
||||
"label": "Submit Generated Invoices"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2021-04-19 15:24:27.550797",
|
||||
"links": [
|
||||
{
|
||||
"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",
|
||||
"module": "Accounts",
|
||||
"name": "Subscription",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@ -309,5 +301,6 @@
|
||||
],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
@ -2,14 +2,17 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.data import (
|
||||
add_days,
|
||||
add_months,
|
||||
add_to_date,
|
||||
cint,
|
||||
cstr,
|
||||
date_diff,
|
||||
flt,
|
||||
get_last_day,
|
||||
@ -17,8 +20,7 @@ from frappe.utils.data import (
|
||||
nowdate,
|
||||
)
|
||||
|
||||
import erpnext
|
||||
from erpnext import get_default_company
|
||||
from erpnext import get_default_company, get_default_cost_center
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
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
|
||||
|
||||
|
||||
class InvoiceCancelled(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class InvoiceNotCancelled(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
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=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
|
||||
beginning of the billing period and end of the billing period.
|
||||
|
||||
The beginning of the billing period is represented in the doctype as
|
||||
`current_invoice_start` and the end of the billing period is represented
|
||||
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_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
|
||||
self.current_invoice_end = _current_invoice_end
|
||||
|
||||
def get_current_invoice_start(self, date=None):
|
||||
def get_current_invoice_start(
|
||||
self, date: Optional[Union[datetime.date, str]] = None
|
||||
) -> Union[datetime.date, str]:
|
||||
"""
|
||||
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
|
||||
@ -75,13 +83,13 @@ class Subscription(Document):
|
||||
|
||||
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.
|
||||
|
||||
If the subscription is in trial period, it will be set as the end of the
|
||||
trial period.
|
||||
|
||||
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
|
||||
`Subscription Plan` in the `Subscription`.
|
||||
@ -105,24 +113,13 @@ class Subscription(Document):
|
||||
_current_invoice_end = get_last_day(date)
|
||||
|
||||
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_interval_count = billing_info[0]["billing_interval_count"]
|
||||
calendar_months = get_calendar_months(billing_interval_count)
|
||||
calendar_month = 0
|
||||
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"
|
||||
)
|
||||
_end = add_months(getdate(date), billing_interval_count - 1)
|
||||
_current_invoice_end = get_last_day(_end)
|
||||
|
||||
if self.end_date and getdate(_current_invoice_end) > getdate(self.end_date):
|
||||
_current_invoice_end = self.end_date
|
||||
@ -130,7 +127,7 @@ class Subscription(Document):
|
||||
return _current_invoice_end
|
||||
|
||||
@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
|
||||
same billing interval
|
||||
@ -138,10 +135,9 @@ class Subscription(Document):
|
||||
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"))
|
||||
|
||||
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`.
|
||||
|
||||
You shouldn't need to call this directly. Use `get_billing_cycle` instead.
|
||||
"""
|
||||
plan_names = [plan.plan for plan in self.plans]
|
||||
@ -156,72 +152,65 @@ class Subscription(Document):
|
||||
|
||||
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.
|
||||
|
||||
You shouldn't need to call this directly. Use `get_billing_cycle` instead.
|
||||
"""
|
||||
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:
|
||||
data = dict()
|
||||
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
|
||||
if interval not in ["Day", "Week"]:
|
||||
data["days"] = -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):
|
||||
"""
|
||||
Sets the `Subscription` `status` based on the preference set in `Subscription Settings`.
|
||||
return data
|
||||
|
||||
Used when the `Subscription` needs to decide what to do after the current generated
|
||||
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):
|
||||
def set_subscription_status(self) -> None:
|
||||
"""
|
||||
Sets the status of the `Subscription`
|
||||
"""
|
||||
if self.is_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"
|
||||
elif self.is_past_grace_period():
|
||||
subscription_settings = frappe.get_single("Subscription Settings")
|
||||
self.status = "Cancelled" if cint(subscription_settings.cancel_after_grace) else "Unpaid"
|
||||
self.status = self.get_status_for_past_grace_period()
|
||||
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():
|
||||
self.status = "Past Due Date"
|
||||
elif not self.has_outstanding_invoice():
|
||||
self.status = "Active"
|
||||
elif self.is_new_subscription():
|
||||
elif not self.has_outstanding_invoice() or self.is_new_subscription():
|
||||
self.status = "Active"
|
||||
|
||||
self.save()
|
||||
|
||||
def is_trialling(self):
|
||||
def is_trialling(self) -> bool:
|
||||
"""
|
||||
Returns `True` if the `Subscription` is in trial period.
|
||||
"""
|
||||
return not self.period_has_passed(self.trial_period_end) and self.is_new_subscription()
|
||||
|
||||
@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
|
||||
"""
|
||||
@ -229,61 +218,59 @@ class Subscription(Document):
|
||||
if not end_date:
|
||||
return True
|
||||
|
||||
end_date = getdate(end_date)
|
||||
return getdate() > getdate(end_date)
|
||||
return getdate(frappe.flags.current_date) > 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
|
||||
"""
|
||||
current_invoice = self.get_current_invoice()
|
||||
if self.current_invoice_is_past_due(current_invoice):
|
||||
subscription_settings = frappe.get_single("Subscription Settings")
|
||||
grace_period = cint(subscription_settings.grace_period)
|
||||
if not self.current_invoice_is_past_due():
|
||||
return
|
||||
|
||||
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
|
||||
"""
|
||||
if not current_invoice:
|
||||
current_invoice = self.get_current_invoice()
|
||||
|
||||
if not current_invoice or self.is_paid(current_invoice):
|
||||
if not self.current_invoice or self.is_paid(self.current_invoice):
|
||||
return False
|
||||
else:
|
||||
return getdate() > getdate(current_invoice.due_date)
|
||||
|
||||
def get_current_invoice(self):
|
||||
"""
|
||||
Returns the most recent generated invoice.
|
||||
"""
|
||||
doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
|
||||
return getdate(frappe.flags.current_date) >= getdate(self.current_invoice.due_date)
|
||||
|
||||
if len(self.invoices):
|
||||
current = self.invoices[-1]
|
||||
if frappe.db.exists(doctype, current.get("invoice")):
|
||||
doc = frappe.get_doc(doctype, current.get("invoice"))
|
||||
return doc
|
||||
else:
|
||||
frappe.throw(_("Invoice {0} no longer exists").format(current.get("invoice")))
|
||||
@property
|
||||
def invoice_document_type(self) -> str:
|
||||
return "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
|
||||
|
||||
def is_new_subscription(self):
|
||||
def is_new_subscription(self) -> bool:
|
||||
"""
|
||||
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_plans_billing_cycle(self.get_billing_cycle_and_interval())
|
||||
self.validate_end_date()
|
||||
self.validate_to_follow_calendar_months()
|
||||
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`
|
||||
"""
|
||||
@ -297,7 +284,7 @@ class Subscription(Document):
|
||||
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"))
|
||||
|
||||
def validate_end_date(self):
|
||||
def validate_end_date(self) -> None:
|
||||
billing_cycle_info = self.get_billing_cycle_data()
|
||||
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)
|
||||
)
|
||||
|
||||
def validate_to_follow_calendar_months(self):
|
||||
if self.follow_calendar_months:
|
||||
billing_info = self.get_billing_cycle_and_interval()
|
||||
def validate_to_follow_calendar_months(self) -> None:
|
||||
if not self.follow_calendar_months:
|
||||
return
|
||||
|
||||
if not self.end_date:
|
||||
frappe.throw(_("Subscription End Date is mandatory to follow calendar months"))
|
||||
billing_info = self.get_billing_cycle_and_interval()
|
||||
|
||||
if billing_info[0]["billing_interval"] != "Month":
|
||||
frappe.throw(
|
||||
_("Billing Interval in Subscription Plan must be Month to follow calendar months")
|
||||
)
|
||||
if not self.end_date:
|
||||
frappe.throw(_("Subscription End Date is mandatory 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?
|
||||
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
|
||||
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"
|
||||
|
||||
invoice = self.create_invoice(prorate)
|
||||
self.append("invoices", {"document_type": doctype, "invoice": invoice.name})
|
||||
|
||||
self.save()
|
||||
|
||||
return invoice
|
||||
|
||||
def create_invoice(self, prorate):
|
||||
def create_invoice(
|
||||
self,
|
||||
from_date: Optional[Union[str, datetime.date]] = None,
|
||||
to_date: Optional[Union[str, datetime.date]] = None,
|
||||
) -> Document:
|
||||
"""
|
||||
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
|
||||
# Earlier subscription didn't had any company field
|
||||
company = self.get("company") or get_default_company()
|
||||
if not company:
|
||||
# fmt: off
|
||||
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.set_posting_time = 1
|
||||
invoice.posting_date = (
|
||||
@ -363,17 +350,17 @@ class Subscription(Document):
|
||||
|
||||
invoice.cost_center = self.cost_center
|
||||
|
||||
if doctype == "Sales Invoice":
|
||||
if self.invoice_document_type == "Sales Invoice":
|
||||
invoice.customer = self.party
|
||||
else:
|
||||
invoice.supplier = self.party
|
||||
if frappe.db.get_value("Supplier", self.party, "tax_withholding_category"):
|
||||
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)
|
||||
|
||||
## Add dimensions in invoice for subscription:
|
||||
# Add dimensions in invoice for subscription:
|
||||
accounting_dimensions = get_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`
|
||||
# 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:
|
||||
item["cost_center"] = self.cost_center
|
||||
invoice.append("items", item)
|
||||
@ -390,9 +377,9 @@ class Subscription(Document):
|
||||
# Taxes
|
||||
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
|
||||
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
|
||||
|
||||
if tax_template:
|
||||
@ -424,8 +411,9 @@ class Subscription(Document):
|
||||
invoice.apply_discount_on = discount_on if discount_on else "Grand Total"
|
||||
|
||||
# Subscription period
|
||||
invoice.from_date = self.current_invoice_start
|
||||
invoice.to_date = self.current_invoice_end
|
||||
invoice.subscription = self.name
|
||||
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
|
||||
|
||||
@ -437,13 +425,20 @@ class Subscription(Document):
|
||||
|
||||
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`
|
||||
"""
|
||||
if prorate is None:
|
||||
prorate = False
|
||||
|
||||
if prorate:
|
||||
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 = []
|
||||
@ -465,7 +460,11 @@ class Subscription(Document):
|
||||
"item_code": item_code,
|
||||
"qty": plan.qty,
|
||||
"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,
|
||||
}
|
||||
@ -503,254 +502,184 @@ class Subscription(Document):
|
||||
|
||||
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
|
||||
as need be. It calls either of these methods depending the `Subscription` status:
|
||||
1. `process_for_active`
|
||||
2. `process_for_past_due`
|
||||
"""
|
||||
if self.status == "Active":
|
||||
self.process_for_active()
|
||||
elif self.status in ["Past Due Date", "Unpaid"]:
|
||||
self.process_for_past_due_date()
|
||||
if (
|
||||
not self.is_current_invoice_generated(self.current_invoice_start, self.current_invoice_end)
|
||||
and self.can_generate_new_invoice()
|
||||
):
|
||||
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.save()
|
||||
|
||||
def is_postpaid_to_invoice(self):
|
||||
return getdate() > getdate(self.current_invoice_end) or (
|
||||
getdate() >= getdate(self.current_invoice_end)
|
||||
and getdate(self.current_invoice_end) == getdate(self.current_invoice_start)
|
||||
)
|
||||
def can_generate_new_invoice(self) -> 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()
|
||||
):
|
||||
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):
|
||||
if not self.generate_invoice_at_period_start:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
if self.is_new_subscription() and getdate() >= getdate(self.current_invoice_start):
|
||||
return True
|
||||
|
||||
# Check invoice dates and make sure it doesn't have outstanding invoices
|
||||
return getdate() >= getdate(self.current_invoice_start)
|
||||
|
||||
def is_current_invoice_generated(self, _current_start_date=None, _current_end_date=None):
|
||||
invoice = self.get_current_invoice()
|
||||
|
||||
def is_current_invoice_generated(
|
||||
self,
|
||||
_current_start_date: Union[datetime.date, str] = None,
|
||||
_current_end_date: Union[datetime.date, str] = None,
|
||||
) -> bool:
|
||||
if not (_current_start_date and _current_end_date):
|
||||
_current_start_date, _current_end_date = self.update_subscription_period(
|
||||
date=add_days(self.current_invoice_end, 1), return_date=True
|
||||
_current_start_date, _current_end_date = self._get_subscription_period(
|
||||
date=add_days(self.current_invoice_end, 1)
|
||||
)
|
||||
|
||||
if invoice and getdate(_current_start_date) <= getdate(invoice.posting_date) <= getdate(
|
||||
_current_end_date
|
||||
):
|
||||
if self.current_invoice and getdate(_current_start_date) <= getdate(
|
||||
self.current_invoice.posting_date
|
||||
) <= getdate(_current_end_date):
|
||||
return True
|
||||
|
||||
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'.
|
||||
|
||||
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'
|
||||
Adds property for accessing the current_invoice
|
||||
"""
|
||||
return self.get_current_invoice()
|
||||
|
||||
if 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()):
|
||||
def get_current_invoice(self) -> Union[Document, None]:
|
||||
"""
|
||||
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")
|
||||
self.generate_invoice(prorate)
|
||||
if invoice:
|
||||
return frappe.get_doc(self.invoice_document_type, invoice[0])
|
||||
|
||||
if getdate() > getdate(self.current_invoice_end) and self.is_prepaid_to_invoice():
|
||||
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):
|
||||
def cancel_subscription_at_period_end(self) -> None:
|
||||
"""
|
||||
Called when `Subscription.cancel_at_period_end` is truthy
|
||||
"""
|
||||
if self.end_date and getdate() < getdate(self.end_date):
|
||||
return
|
||||
|
||||
self.status = "Cancelled"
|
||||
if not self.cancelation_date:
|
||||
self.cancelation_date = nowdate()
|
||||
self.cancelation_date = nowdate()
|
||||
|
||||
def process_for_past_due_date(self):
|
||||
"""
|
||||
Called by `process` if the status of the `Subscription` is 'Past Due Date'.
|
||||
|
||||
The possible outcomes of this method are:
|
||||
1. Change the `Subscription` status to 'Active'
|
||||
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)
|
||||
@property
|
||||
def invoices(self) -> List[Dict]:
|
||||
return frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
filters={"subscription": self.name},
|
||||
order_by="from_date asc",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_paid(invoice):
|
||||
def is_paid(invoice: Document) -> bool:
|
||||
"""
|
||||
Return `True` if the given invoice is 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
|
||||
"""
|
||||
doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
|
||||
current_invoice = self.get_current_invoice()
|
||||
invoice_list = [d.invoice for d in self.invoices]
|
||||
|
||||
outstanding_invoices = frappe.get_all(
|
||||
doctype, fields=["name"], filters={"status": ("!=", "Paid"), "name": ("in", invoice_list)}
|
||||
return frappe.db.count(
|
||||
self.invoice_document_type,
|
||||
{
|
||||
"subscription": self.name,
|
||||
"status": ["!=", "Paid"],
|
||||
},
|
||||
)
|
||||
|
||||
if outstanding_invoices:
|
||||
return True
|
||||
else:
|
||||
False
|
||||
|
||||
def cancel_subscription(self):
|
||||
@frappe.whitelist()
|
||||
def cancel_subscription(self) -> None:
|
||||
"""
|
||||
This sets the subscription as cancelled. It will stop invoices from being generated
|
||||
but it will not affect already created invoices.
|
||||
"""
|
||||
if self.status != "Cancelled":
|
||||
to_generate_invoice = (
|
||||
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()
|
||||
if self.status == "Cancelled":
|
||||
frappe.throw(_("subscription is already cancelled."), InvoiceCancelled)
|
||||
|
||||
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
|
||||
subscription and the `Subscription` will lose all the history of generated invoices
|
||||
it has.
|
||||
"""
|
||||
if self.status == "Cancelled":
|
||||
self.status = "Active"
|
||||
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."))
|
||||
if not self.status == "Cancelled":
|
||||
frappe.throw(_("You cannot restart a Subscription that is not cancelled."), InvoiceNotCancelled)
|
||||
|
||||
def get_precision(self):
|
||||
invoice = self.get_current_invoice()
|
||||
if invoice:
|
||||
return invoice.precision("grand_total")
|
||||
self.status = "Active"
|
||||
self.cancelation_date = None
|
||||
self.update_subscription_period(frappe.flags.current_date or nowdate())
|
||||
self.save()
|
||||
|
||||
|
||||
def get_calendar_months(billing_interval):
|
||||
calendar_months = []
|
||||
start = 0
|
||||
while start < 12:
|
||||
start += billing_interval
|
||||
calendar_months.append(start)
|
||||
|
||||
return calendar_months
|
||||
def is_prorate() -> int:
|
||||
return cint(frappe.db.get_single_value("Subscription Settings", "prorate"))
|
||||
|
||||
|
||||
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:
|
||||
prorate_factor = 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 1
|
||||
|
||||
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
|
||||
"""
|
||||
subscriptions = get_all_subscriptions()
|
||||
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:
|
||||
for subscription in frappe.get_all("Subscription", {"status": ("!=", "Cancelled")}, pluck="name"):
|
||||
try:
|
||||
subscription = frappe.get_doc("Subscription", data["name"])
|
||||
subscription = frappe.get_doc("Subscription", subscription)
|
||||
subscription.process()
|
||||
frappe.db.commit()
|
||||
except frappe.ValidationError:
|
||||
frappe.db.rollback()
|
||||
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()
|
||||
|
@ -11,6 +11,7 @@ from frappe.utils.data import (
|
||||
date_diff,
|
||||
flt,
|
||||
get_date_str,
|
||||
getdate,
|
||||
nowdate,
|
||||
)
|
||||
|
||||
@ -90,10 +91,18 @@ def create_parties():
|
||||
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()
|
||||
create_parties()
|
||||
reset_settings()
|
||||
|
||||
def test_create_subscription_with_trial_with_correct_period(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
@ -116,8 +125,6 @@ class TestSubscription(unittest.TestCase):
|
||||
self.assertEqual(subscription.invoices, [])
|
||||
self.assertEqual(subscription.status, "Trialling")
|
||||
|
||||
subscription.delete()
|
||||
|
||||
def test_create_subscription_without_trial_with_correct_period(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
@ -133,8 +140,6 @@ class TestSubscription(unittest.TestCase):
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
subscription.delete()
|
||||
|
||||
def test_create_subscription_trial_with_wrong_dates(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
@ -144,7 +149,6 @@ class TestSubscription(unittest.TestCase):
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
|
||||
self.assertRaises(frappe.ValidationError, subscription.save)
|
||||
subscription.delete()
|
||||
|
||||
def test_create_subscription_multi_with_different_billing_fails(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
@ -156,7 +160,6 @@ class TestSubscription(unittest.TestCase):
|
||||
subscription.append("plans", {"plan": "_Test Plan Name 3", "qty": 1})
|
||||
|
||||
self.assertRaises(frappe.ValidationError, subscription.save)
|
||||
subscription.delete()
|
||||
|
||||
def test_invoice_is_generated_at_end_of_billing_period(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
@ -169,13 +172,13 @@ class TestSubscription(unittest.TestCase):
|
||||
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()
|
||||
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.current_invoice_start, "2018-01-01")
|
||||
subscription.process()
|
||||
self.assertEqual(subscription.current_invoice_start, "2018-02-01")
|
||||
self.assertEqual(subscription.current_invoice_end, "2018-02-28")
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
subscription.delete()
|
||||
|
||||
def test_status_goes_back_to_active_after_invoice_is_paid(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
@ -183,7 +186,9 @@ class TestSubscription(unittest.TestCase):
|
||||
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
|
||||
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(len(subscription.invoices), 1)
|
||||
|
||||
subscription.delete()
|
||||
|
||||
def test_subscription_cancel_after_grace_period(self):
|
||||
settings = frappe.get_single("Subscription Settings")
|
||||
default_grace_period_action = settings.cancel_after_grace
|
||||
settings.cancel_after_grace = 1
|
||||
settings.save()
|
||||
|
||||
@ -215,20 +217,18 @@ class TestSubscription(unittest.TestCase):
|
||||
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()
|
||||
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
frappe.flags.current_date = "2018-01-31"
|
||||
subscription.process() # 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")
|
||||
|
||||
settings.cancel_after_grace = default_grace_period_action
|
||||
settings.save()
|
||||
subscription.delete()
|
||||
|
||||
def test_subscription_unpaid_after_grace_period(self):
|
||||
settings = frappe.get_single("Subscription Settings")
|
||||
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.save()
|
||||
subscription.delete()
|
||||
|
||||
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.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
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()
|
||||
|
||||
frappe.flags.current_date = subscription.current_invoice_end
|
||||
|
||||
subscription.process() # generate first invoice
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
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):
|
||||
settings = frappe.get_single("Subscription Settings")
|
||||
@ -276,6 +281,8 @@ class TestSubscription(unittest.TestCase):
|
||||
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
|
||||
|
||||
self.assertEqual(subscription.status, "Past Due Date")
|
||||
@ -292,7 +299,6 @@ class TestSubscription(unittest.TestCase):
|
||||
|
||||
settings.grace_period = grace_period
|
||||
settings.save()
|
||||
subscription.delete()
|
||||
|
||||
def test_subscription_remains_active_during_invoice_period(self):
|
||||
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(len(subscription.invoices), 0)
|
||||
|
||||
subscription.delete()
|
||||
|
||||
def test_subscription_cancelation(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
@ -331,8 +335,6 @@ class TestSubscription(unittest.TestCase):
|
||||
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
|
||||
subscription.delete()
|
||||
|
||||
def test_subscription_cancellation_invoices(self):
|
||||
settings = frappe.get_single("Subscription Settings")
|
||||
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(subscription.status, "Cancelled")
|
||||
|
||||
subscription.delete()
|
||||
settings.prorate = to_prorate
|
||||
settings.save()
|
||||
|
||||
@ -395,8 +396,6 @@ class TestSubscription(unittest.TestCase):
|
||||
settings.prorate = to_prorate
|
||||
settings.save()
|
||||
|
||||
subscription.delete()
|
||||
|
||||
def test_subscription_cancellation_invoices_with_prorata_true(self):
|
||||
settings = frappe.get_single("Subscription Settings")
|
||||
to_prorate = settings.prorate
|
||||
@ -422,8 +421,6 @@ class TestSubscription(unittest.TestCase):
|
||||
settings.prorate = to_prorate
|
||||
settings.save()
|
||||
|
||||
subscription.delete()
|
||||
|
||||
def test_subcription_cancellation_and_process(self):
|
||||
settings = frappe.get_single("Subscription Settings")
|
||||
default_grace_period_action = settings.cancel_after_grace
|
||||
@ -437,23 +434,22 @@ class TestSubscription(unittest.TestCase):
|
||||
subscription.start_date = "2018-01-01"
|
||||
subscription.insert()
|
||||
subscription.process() # generate first invoice
|
||||
invoices = len(subscription.invoices)
|
||||
|
||||
# Generate an invoice for the cancelled period
|
||||
subscription.cancel_subscription()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), invoices)
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
subscription.process()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), invoices)
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
subscription.process()
|
||||
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.save()
|
||||
subscription.delete()
|
||||
|
||||
def test_subscription_restart_and_process(self):
|
||||
settings = frappe.get_single("Subscription Settings")
|
||||
@ -468,6 +464,7 @@ class TestSubscription(unittest.TestCase):
|
||||
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
|
||||
|
||||
# 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()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
subscription.process()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
subscription.process()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
settings.cancel_after_grace = default_grace_period_action
|
||||
settings.save()
|
||||
subscription.delete()
|
||||
|
||||
def test_subscription_unpaid_back_to_active(self):
|
||||
settings = frappe.get_single("Subscription Settings")
|
||||
@ -503,8 +499,11 @@ class TestSubscription(unittest.TestCase):
|
||||
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
|
||||
# This should change status to Unpaid since grace period is 0
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
@ -517,12 +516,12 @@ class TestSubscription(unittest.TestCase):
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
# A new invoice is generated
|
||||
frappe.flags.current_date = subscription.current_invoice_start
|
||||
subscription.process()
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
settings.cancel_after_grace = default_grace_period_action
|
||||
settings.save()
|
||||
subscription.delete()
|
||||
|
||||
def test_restart_active_subscription(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
@ -533,8 +532,6 @@ class TestSubscription(unittest.TestCase):
|
||||
|
||||
self.assertRaises(frappe.ValidationError, subscription.restart_subscription)
|
||||
|
||||
subscription.delete()
|
||||
|
||||
def test_subscription_invoice_discount_percentage(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
@ -549,8 +546,6 @@ class TestSubscription(unittest.TestCase):
|
||||
self.assertEqual(invoice.additional_discount_percentage, 10)
|
||||
self.assertEqual(invoice.apply_discount_on, "Grand Total")
|
||||
|
||||
subscription.delete()
|
||||
|
||||
def test_subscription_invoice_discount_amount(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
@ -565,8 +560,6 @@ class TestSubscription(unittest.TestCase):
|
||||
self.assertEqual(invoice.discount_amount, 11)
|
||||
self.assertEqual(invoice.apply_discount_on, "Grand Total")
|
||||
|
||||
subscription.delete()
|
||||
|
||||
def test_prepaid_subscriptions(self):
|
||||
# Create a non pre-billed subscription, processing should not create
|
||||
# invoices.
|
||||
@ -614,8 +607,6 @@ class TestSubscription(unittest.TestCase):
|
||||
settings.prorate = to_prorate
|
||||
settings.save()
|
||||
|
||||
subscription.delete()
|
||||
|
||||
def test_subscription_with_follow_calendar_months(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Supplier"
|
||||
@ -623,14 +614,14 @@ class TestSubscription(unittest.TestCase):
|
||||
subscription.generate_invoice_at_period_start = 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.end_date = "2018-07-15"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
|
||||
subscription.save()
|
||||
|
||||
# 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'
|
||||
# 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"
|
||||
self.assertEqual(get_date_str(subscription.current_invoice_end), "2018-03-31")
|
||||
|
||||
def test_subscription_generate_invoice_past_due(self):
|
||||
@ -639,11 +630,12 @@ class TestSubscription(unittest.TestCase):
|
||||
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'
|
||||
# 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()
|
||||
|
||||
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()
|
||||
@ -652,8 +644,8 @@ class TestSubscription(unittest.TestCase):
|
||||
|
||||
# Now the Subscription is unpaid
|
||||
# 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()
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
|
||||
@ -662,7 +654,7 @@ class TestSubscription(unittest.TestCase):
|
||||
subscription.party_type = "Supplier"
|
||||
subscription.party = "_Test Supplier"
|
||||
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.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
|
||||
subscription.save()
|
||||
@ -682,7 +674,7 @@ class TestSubscription(unittest.TestCase):
|
||||
subscription.party = "_Test Subscription Customer"
|
||||
subscription.generate_invoice_at_period_start = 1
|
||||
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.append("plans", {"plan": "_Test Plan Multicurrency", "qty": 1})
|
||||
subscription.save()
|
||||
@ -692,5 +684,47 @@ class TestSubscription(unittest.TestCase):
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
# 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")
|
||||
|
||||
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"),
|
||||
)
|
||||
|
@ -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.delete_saudi_doctypes
|
||||
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)
|
||||
|
||||
[post_model_sync]
|
||||
|
17
erpnext/patches/v14_0/update_subscription_details.py
Normal file
17
erpnext/patches/v14_0/update_subscription_details.py
Normal 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")
|
Loading…
Reference in New Issue
Block a user