mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-29 18:04:46 +00:00
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:
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
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")
|
||||||
Reference in New Issue
Block a user