feat: Enhancement in subscription (#22263)

* feat: Add supplier in subscription doctype

* fix: Code cleanup

* fix: Add dynamic link in subscription invoices

* fix: Multiple enhanccement in subscription

* feat: Follow calendar months in subscription

* fix: Test Cases and patch

* fix: Patch

* fix: Update patch and add fixes

* fix: Update permission for subscription settings

* fix: Patch and Test

* fix: Add cost center dimension in Subscripiton
This commit is contained in:
Deepesh Garg 2020-07-23 11:11:23 +05:30 committed by GitHub
parent a18f2ec23e
commit 9c49f2d886
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 678 additions and 495 deletions

View File

@ -2,6 +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) {
frm.set_query('party_type', function() {
return {
filters : {
name: ['in', ['Customer', 'Supplier']]
}
}
});
},
refresh: function(frm) { refresh: function(frm) {
if(!frm.is_new()){ if(!frm.is_new()){
if(frm.doc.status !== 'Cancelled'){ if(frm.doc.status !== 'Cancelled'){

View File

@ -6,14 +6,18 @@
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"customer", "party_type",
"cb_1",
"status", "status",
"cb_1",
"party",
"subscription_period", "subscription_period",
"start", "start_date",
"end_date",
"cancelation_date", "cancelation_date",
"trial_period_start", "trial_period_start",
"trial_period_end", "trial_period_end",
"follow_calendar_months",
"generate_new_invoices_past_due_date",
"column_break_11", "column_break_11",
"current_invoice_start", "current_invoice_start",
"current_invoice_end", "current_invoice_end",
@ -23,7 +27,8 @@
"sb_4", "sb_4",
"plans", "plans",
"sb_1", "sb_1",
"tax_template", "sales_tax_template",
"purchase_tax_template",
"sb_2", "sb_2",
"apply_additional_discount", "apply_additional_discount",
"cb_2", "cb_2",
@ -32,18 +37,10 @@
"sb_3", "sb_3",
"invoices", "invoices",
"accounting_dimensions_section", "accounting_dimensions_section",
"cost_center",
"dimension_col_break" "dimension_col_break"
], ],
"fields": [ "fields": [
{
"fieldname": "customer",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Customer",
"options": "Customer",
"reqd": 1,
"set_only_once": 1
},
{ {
"allow_on_submit": 1, "allow_on_submit": 1,
"fieldname": "cb_1", "fieldname": "cb_1",
@ -53,7 +50,7 @@
"fieldname": "status", "fieldname": "status",
"fieldtype": "Select", "fieldtype": "Select",
"label": "Status", "label": "Status",
"options": "\nTrialling\nActive\nPast Due Date\nCancelled\nUnpaid", "options": "\nTrialling\nActive\nPast Due Date\nCancelled\nUnpaid\nCompleted",
"read_only": 1 "read_only": 1
}, },
{ {
@ -61,12 +58,6 @@
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Subscription Period" "label": "Subscription Period"
}, },
{
"fieldname": "start",
"fieldtype": "Date",
"label": "Subscription Start Date",
"set_only_once": 1
},
{ {
"fieldname": "cancelation_date", "fieldname": "cancelation_date",
"fieldtype": "Date", "fieldtype": "Date",
@ -137,16 +128,11 @@
"reqd": 1 "reqd": 1
}, },
{ {
"depends_on": "eval:['Customer', 'Supplier'].includes(doc.party_type)",
"fieldname": "sb_1", "fieldname": "sb_1",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Taxes" "label": "Taxes"
}, },
{
"fieldname": "tax_template",
"fieldtype": "Link",
"label": "Sales Taxes and Charges Template",
"options": "Sales Taxes and Charges Template"
},
{ {
"fieldname": "sb_2", "fieldname": "sb_2",
"fieldtype": "Section Break", "fieldtype": "Section Break",
@ -195,10 +181,74 @@
{ {
"fieldname": "dimension_col_break", "fieldname": "dimension_col_break",
"fieldtype": "Column Break" "fieldtype": "Column Break"
},
{
"fieldname": "party_type",
"fieldtype": "Link",
"label": "Party Type",
"options": "DocType",
"reqd": 1,
"set_only_once": 1
},
{
"fieldname": "party",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Party",
"options": "party_type",
"reqd": 1,
"set_only_once": 1
},
{
"depends_on": "eval:doc.party_type === 'Customer'",
"fieldname": "sales_tax_template",
"fieldtype": "Link",
"label": "Sales Taxes and Charges Template",
"options": "Sales Taxes and Charges Template"
},
{
"depends_on": "eval:doc.party_type === 'Supplier'",
"fieldname": "purchase_tax_template",
"fieldtype": "Link",
"label": "Purchase Taxes and Charges Template",
"options": "Purchase Taxes and Charges Template"
},
{
"default": "0",
"description": "If this is checked subsequent new invoices will be created on calendar month and quarter start dates irrespective of current invoice start date",
"fieldname": "follow_calendar_months",
"fieldtype": "Check",
"label": "Follow Calendar Months",
"set_only_once": 1
},
{
"default": "0",
"description": "New invoices will be generated as per schedule even if current invoices are unpaid or past due date",
"fieldname": "generate_new_invoices_past_due_date",
"fieldtype": "Check",
"label": "Generate New Invoices Past Due Date"
},
{
"fieldname": "end_date",
"fieldtype": "Date",
"label": "Subscription End Date",
"set_only_once": 1
},
{
"fieldname": "start_date",
"fieldtype": "Date",
"label": "Subscription Start Date",
"set_only_once": 1
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
} }
], ],
"links": [], "links": [],
"modified": "2020-01-27 14:37:32.845173", "modified": "2020-06-25 10:52:52.265105",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Subscription", "name": "Subscription",

View File

@ -7,7 +7,7 @@ from __future__ import unicode_literals
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 nowdate, getdate, cint, add_days, date_diff, get_last_day, add_to_date, flt from frappe.utils.data import nowdate, getdate, cstr, cint, add_days, date_diff, get_last_day, add_to_date, flt
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions
@ -15,7 +15,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import g
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) self.update_subscription_period(self.start_date)
def update_subscription_period(self, date=None): def update_subscription_period(self, date=None):
""" """
@ -35,7 +35,9 @@ class Subscription(Document):
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
date. date.
""" """
if self.trial_period_start and self.is_trialling(): if self.is_new_subscription() and self.trial_period_end and getdate(self.trial_period_end) > getdate(self.start_date):
self.current_invoice_start = add_days(self.trial_period_end, 1)
elif self.trial_period_start and self.is_trialling():
self.current_invoice_start = self.trial_period_start self.current_invoice_start = self.trial_period_start
elif date: elif date:
self.current_invoice_start = date self.current_invoice_start = date
@ -53,15 +55,45 @@ class Subscription(Document):
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`.
""" """
if self.is_trialling(): if self.is_trialling() and getdate(self.current_invoice_start) < getdate(self.trial_period_end):
self.current_invoice_end = self.trial_period_end self.current_invoice_end = self.trial_period_end
else: else:
billing_cycle_info = self.get_billing_cycle_data() billing_cycle_info = self.get_billing_cycle_data()
if billing_cycle_info: if billing_cycle_info:
self.current_invoice_end = add_to_date(self.current_invoice_start, **billing_cycle_info) if self.is_new_subscription() and getdate(self.start_date) < getdate(self.current_invoice_start):
self.current_invoice_end = add_to_date(self.start_date, **billing_cycle_info)
# For cases where trial period is for an entire billing interval
if getdate(self.current_invoice_end) < getdate(self.current_invoice_start):
self.current_invoice_end = add_to_date(self.current_invoice_start, **billing_cycle_info)
else:
self.current_invoice_end = add_to_date(self.current_invoice_start, **billing_cycle_info)
else: else:
self.current_invoice_end = get_last_day(self.current_invoice_start) self.current_invoice_end = get_last_day(self.current_invoice_start)
if self.follow_calendar_months:
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(self.current_invoice_end).month
current_invoice_end_year = getdate(self.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(self.current_invoice_start).month != 1:
calendar_month = 12
current_invoice_end_year -= 1
self.current_invoice_end = get_last_day(cstr(current_invoice_end_year) + '-' \
+ cstr(calendar_month) + '-01')
if self.end_date and getdate(self.current_invoice_end) > getdate(self.end_date):
self.current_invoice_end = self.end_date
@staticmethod @staticmethod
def validate_plans_billing_cycle(billing_cycle_data): def validate_plans_billing_cycle(billing_cycle_data):
""" """
@ -132,21 +164,22 @@ class Subscription(Document):
""" """
if self.is_trialling(): if self.is_trialling():
self.status = 'Trialling' self.status = 'Trialling'
elif self.status == 'Past Due Date' and self.is_past_grace_period(): elif self.status == 'Active' and self.end_date and getdate() > getdate(self.end_date):
self.status = 'Completed'
elif self.is_past_grace_period():
subscription_settings = frappe.get_single('Subscription Settings') subscription_settings = frappe.get_single('Subscription Settings')
self.status = 'Cancelled' if cint(subscription_settings.cancel_after_grace) else 'Unpaid' self.status = 'Cancelled' if cint(subscription_settings.cancel_after_grace) else 'Unpaid'
elif self.status == 'Past Due Date' and not self.has_outstanding_invoice(): elif self.current_invoice_is_past_due() and not self.is_past_grace_period():
self.status = 'Active'
elif self.current_invoice_is_past_due():
self.status = 'Past Due Date' self.status = 'Past Due Date'
elif not self.has_outstanding_invoice():
self.status = 'Active'
elif self.is_new_subscription(): elif self.is_new_subscription():
self.status = 'Active' self.status = 'Active'
# todo: then generate new invoice
self.save() self.save()
def is_trialling(self): def is_trialling(self):
""" """
Returns `True` if the `Subscription` is 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()
@ -160,7 +193,7 @@ class Subscription(Document):
return True return True
end_date = getdate(end_date) end_date = getdate(end_date)
return getdate(nowdate()) > getdate(end_date) return getdate() > getdate(end_date)
def is_past_grace_period(self): def is_past_grace_period(self):
""" """
@ -171,7 +204,7 @@ class Subscription(Document):
subscription_settings = frappe.get_single('Subscription Settings') subscription_settings = frappe.get_single('Subscription Settings')
grace_period = cint(subscription_settings.grace_period) grace_period = cint(subscription_settings.grace_period)
return getdate(nowdate()) > add_days(current_invoice.due_date, grace_period) return getdate() > add_days(current_invoice.due_date, grace_period)
def current_invoice_is_past_due(self, current_invoice=None): def current_invoice_is_past_due(self, current_invoice=None):
""" """
@ -180,22 +213,24 @@ class Subscription(Document):
if not current_invoice: if not current_invoice:
current_invoice = self.get_current_invoice() current_invoice = self.get_current_invoice()
if not current_invoice: if not current_invoice or self.is_paid(current_invoice):
return False return False
else: else:
return getdate(nowdate()) > getdate(current_invoice.due_date) return getdate() > getdate(current_invoice.due_date)
def get_current_invoice(self): def get_current_invoice(self):
""" """
Returns the most recent generated invoice. Returns the most recent generated invoice.
""" """
doctype = 'Sales Invoice' if self.party_type == 'Customer' else 'Purchase Invoice'
if len(self.invoices): if len(self.invoices):
current = self.invoices[-1] current = self.invoices[-1]
if frappe.db.exists('Sales Invoice', current.invoice): if frappe.db.exists(doctype, current.get('invoice')):
doc = frappe.get_doc('Sales Invoice', current.invoice) doc = frappe.get_doc(doctype, current.get('invoice'))
return doc return doc
else: else:
frappe.throw(_('Invoice {0} no longer exists').format(current.invoice)) frappe.throw(_('Invoice {0} no longer exists').format(current.get('invoice')))
def is_new_subscription(self): def is_new_subscription(self):
""" """
@ -206,6 +241,8 @@ class Subscription(Document):
def validate(self): def validate(self):
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_to_follow_calendar_months()
def validate_trial_period(self): def validate_trial_period(self):
""" """
@ -215,34 +252,72 @@ class Subscription(Document):
if getdate(self.trial_period_end) < getdate(self.trial_period_start): if getdate(self.trial_period_end) < getdate(self.trial_period_start):
frappe.throw(_('Trial Period End Date Cannot be before Trial Period Start Date')) frappe.throw(_('Trial Period End Date Cannot be before Trial Period Start Date'))
elif self.trial_period_start or self.trial_period_end: if self.trial_period_start and not self.trial_period_end:
frappe.throw(_('Both Trial Period Start Date and Trial Period End Date must be set')) frappe.throw(_('Both Trial Period Start Date and Trial Period End Date must be set'))
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):
billing_cycle_info = self.get_billing_cycle_data()
end_date = add_to_date(self.start_date, **billing_cycle_info)
if self.end_date and getdate(self.end_date) <= getdate(end_date):
frappe.throw(_('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()
if not self.end_date:
frappe.throw(_('Subscription End Date is mandatory to follow calendar months'))
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): def after_insert(self):
# 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, prorate=0):
""" """
Creates a `Sales Invoice` for the `Subscription`, updates `self.invoices` and Creates a `Invoice` for the `Subscription`, updates `self.invoices` and
saves the `Subscription`. saves the `Subscription`.
""" """
doctype = 'Sales Invoice' if self.party_type == 'Customer' else 'Purchase Invoice'
invoice = self.create_invoice(prorate) invoice = self.create_invoice(prorate)
self.append('invoices', {'invoice': invoice.name}) self.append('invoices', {
'document_type': doctype,
'invoice': invoice.name
})
self.save() self.save()
return invoice return invoice
def create_invoice(self, prorate): def create_invoice(self, prorate):
""" """
Creates a `Sales Invoice`, submits it and returns it Creates a `Invoice`, submits it and returns it
""" """
invoice = frappe.new_doc('Sales Invoice') doctype = 'Sales Invoice' if self.party_type == 'Customer' else 'Purchase Invoice'
invoice.set_posting_time = 1
invoice.posting_date = self.current_invoice_start
invoice.customer = self.customer
## Add dimesnions in invoice for subscription: invoice = frappe.new_doc(doctype)
invoice.set_posting_time = 1
invoice.posting_date = self.current_invoice_start if self.generate_invoice_at_period_start \
else self.current_invoice_end
invoice.cost_center = self.cost_center
if doctype == '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 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:
@ -255,18 +330,25 @@ class Subscription(Document):
# 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, prorate)
for item in items_list: for item in items_list:
invoice.append('items', item) invoice.append('items', item)
# Taxes # Taxes
if self.tax_template: tax_template = ''
invoice.taxes_and_charges = self.tax_template
if doctype == 'Sales Invoice' and self.sales_tax_template:
tax_template = self.sales_tax_template
if doctype == 'Purchase Invoice' and self.purchase_tax_template:
tax_template = self.purchase_tax_template
if tax_template:
invoice.taxes_and_charges = tax_template
invoice.set_taxes() invoice.set_taxes()
# Due date # Due date
invoice.append( invoice.append(
'payment_schedule', 'payment_schedule',
{ {
'due_date': add_days(self.current_invoice_end, cint(self.days_until_due)), 'due_date': add_days(invoice.posting_date, cint(self.days_until_due)),
'invoice_portion': 100 'invoice_portion': 100
} }
) )
@ -300,13 +382,42 @@ class Subscription(Document):
prorate_factor = get_prorata_factor(self.current_invoice_end, self.current_invoice_start) prorate_factor = get_prorata_factor(self.current_invoice_end, self.current_invoice_start)
items = [] items = []
customer = self.customer party = self.party
for plan in plans: for plan in plans:
item_code = frappe.db.get_value("Subscription Plan", plan.plan, "item") plan_doc = frappe.get_doc('Subscription Plan', plan.plan)
if not prorate:
items.append({'item_code': item_code, 'qty': plan.qty, 'rate': get_plan_rate(plan.plan, plan.qty, customer)}) item_code = plan_doc.item
if self.party == 'Customer':
deferred_field = 'enable_deferred_revenue'
else: else:
items.append({'item_code': item_code, 'qty': plan.qty, 'rate': (get_plan_rate(plan.plan, plan.qty, customer) * prorate_factor)}) deferred_field = 'enable_deferred_expense'
deferred = frappe.db.get_value('Item', item_code, deferred_field)
if not prorate:
item = {'item_code': item_code, 'qty': plan.qty, 'rate': get_plan_rate(plan.plan, plan.qty, party,
self.current_invoice_start, self.current_invoice_end), 'cost_center': plan_doc.cost_center}
else:
item = {'item_code': item_code, 'qty': plan.qty, 'rate': get_plan_rate(plan.plan, plan.qty, party,
self.current_invoice_start, self.current_invoice_end, prorate_factor), 'cost_center': plan_doc.cost_center}
if deferred:
item.update({
deferred_field: deferred,
'service_start_date': self.current_invoice_start,
'service_end_date': self.current_invoice_end
})
accounting_dimensions = get_accounting_dimensions()
for dimension in accounting_dimensions:
if plan_doc.get(dimension):
item.update({
dimension: plan_doc.get(dimension)
})
items.append(item)
return items return items
@ -322,12 +433,13 @@ class Subscription(Document):
elif self.status in ['Past Due Date', 'Unpaid']: elif self.status in ['Past Due Date', 'Unpaid']:
self.process_for_past_due_date() self.process_for_past_due_date()
self.set_subscription_status()
self.save() self.save()
def is_postpaid_to_invoice(self): def is_postpaid_to_invoice(self):
return getdate(nowdate()) > getdate(self.current_invoice_end) or \ return getdate() > getdate(self.current_invoice_end) or \
(getdate(nowdate()) >= getdate(self.current_invoice_end) and getdate(self.current_invoice_end) == getdate(self.current_invoice_start)) and \ (getdate() >= getdate(self.current_invoice_end) and getdate(self.current_invoice_end) == getdate(self.current_invoice_start))
not self.has_outstanding_invoice()
def is_prepaid_to_invoice(self): def is_prepaid_to_invoice(self):
if not self.generate_invoice_at_period_start: if not self.generate_invoice_at_period_start:
@ -337,14 +449,12 @@ class Subscription(Document):
return True return True
# Check invoice dates and make sure it doesn't have outstanding invoices # Check invoice dates and make sure it doesn't have outstanding invoices
return getdate(nowdate()) >= getdate(self.current_invoice_start) and not self.has_outstanding_invoice() return getdate() >= getdate(self.current_invoice_start)
def is_current_invoice_paid(self): def is_current_invoice_generated(self):
if self.is_new_subscription(): invoice = self.get_current_invoice()
return False
last_invoice = frappe.get_doc('Sales Invoice', self.invoices[-1].invoice) if invoice and getdate(self.current_invoice_start) <= getdate(invoice.posting_date) <= getdate(self.current_invoice_end):
if getdate(last_invoice.posting_date) == getdate(self.current_invoice_start) and last_invoice.status == 'Paid':
return True return True
return False return False
@ -358,21 +468,23 @@ class Subscription(Document):
2. Change the `Subscription` status to 'Past Due Date' 2. Change the `Subscription` status to 'Past Due Date'
3. Change the `Subscription` status to 'Cancelled' 3. Change the `Subscription` status to 'Cancelled'
""" """
if not self.is_current_invoice_paid() and (self.is_postpaid_to_invoice() or self.is_prepaid_to_invoice()): if getdate() > getdate(self.current_invoice_end) and self.is_prepaid_to_invoice():
self.generate_invoice() self.update_subscription_period(add_days(self.current_invoice_end, 1))
if self.current_invoice_is_past_due():
self.status = 'Past Due Date'
if self.current_invoice_is_past_due() and getdate(nowdate()) > getdate(self.current_invoice_end): if not self.is_current_invoice_generated() and (self.is_postpaid_to_invoice() or self.is_prepaid_to_invoice()):
self.status = 'Past Due Date' prorate = frappe.db.get_single_value('Subscription Settings', 'prorate')
self.generate_invoice(prorate)
if self.cancel_at_period_end and getdate(nowdate()) > getdate(self.current_invoice_end): if self.cancel_at_period_end and getdate() > getdate(self.current_invoice_end):
self.cancel_subscription_at_period_end() self.cancel_subscription_at_period_end()
def cancel_subscription_at_period_end(self): 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: if not self.cancelation_date:
self.cancelation_date = nowdate() self.cancelation_date = nowdate()
@ -390,14 +502,22 @@ class Subscription(Document):
if not current_invoice: if not current_invoice:
frappe.throw(_('Current invoice {0} is missing').format(current_invoice.invoice)) frappe.throw(_('Current invoice {0} is missing').format(current_invoice.invoice))
else: else:
if self.is_not_outstanding(current_invoice): if not self.has_outstanding_invoice():
self.status = 'Active' self.status = 'Active'
self.update_subscription_period(add_days(self.current_invoice_end, 1))
else: else:
self.set_status_grace_period() 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() 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_not_outstanding(invoice): def is_paid(invoice):
""" """
Return `True` if the given invoice is paid Return `True` if the given invoice is paid
""" """
@ -407,11 +527,17 @@ class Subscription(Document):
""" """
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'
current_invoice = self.get_current_invoice() current_invoice = self.get_current_invoice()
if not current_invoice: invoice_list = [d.invoice for d in self.invoices]
return False
outstanding_invoices = frappe.get_all(doctype, fields=['name'],
filters={'status': ('!=', 'Paid'), 'name': ('in', invoice_list)})
if outstanding_invoices:
return True
else: else:
return not self.is_not_outstanding(current_invoice) False
def cancel_subscription(self): def cancel_subscription(self):
""" """
@ -419,7 +545,7 @@ class Subscription(Document):
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 = True if self.status == 'Active' else False 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') to_prorate = frappe.db.get_single_value('Subscription Settings', 'prorate')
self.status = 'Cancelled' self.status = 'Cancelled'
self.cancelation_date = nowdate() self.cancelation_date = nowdate()
@ -435,7 +561,7 @@ class Subscription(Document):
""" """
if self.status == 'Cancelled': if self.status == 'Cancelled':
self.status = 'Active' self.status = 'Active'
self.db_set('start', nowdate()) self.db_set('start_date', nowdate())
self.update_subscription_period(nowdate()) self.update_subscription_period(nowdate())
self.invoices = [] self.invoices = []
self.save() self.save()
@ -447,6 +573,14 @@ class Subscription(Document):
if invoice: if invoice:
return invoice.precision('grand_total') return invoice.precision('grand_total')
def get_calendar_months(billing_interval):
calendar_months = []
start = 0
while start < 12:
start += billing_interval
calendar_months.append(start)
return calendar_months
def get_prorata_factor(period_end, period_start): def get_prorata_factor(period_end, period_start):
diff = flt(date_diff(nowdate(), period_start) + 1) diff = flt(date_diff(nowdate(), period_start) + 1)
@ -469,10 +603,7 @@ def get_all_subscriptions():
""" """
Returns all `Subscription` documents Returns all `Subscription` documents
""" """
return frappe.db.sql( return frappe.db.get_all('Subscription', {'status': ('!=','Cancelled')})
'select name from `tabSubscription` where status != "Cancelled"',
as_dict=1
)
def process(data): def process(data):

View File

@ -4,6 +4,8 @@ frappe.listview_settings['Subscription'] = {
return [__("Trialling"), "green"]; return [__("Trialling"), "green"];
} else if(doc.status === 'Active') { } else if(doc.status === 'Active') {
return [__("Active"), "green"]; return [__("Active"), "green"];
} else if(doc.status === 'Completed') {
return [__("Completed"), "green"];
} else if(doc.status === 'Past Due Date') { } else if(doc.status === 'Past Due Date') {
return [__("Past Due Date"), "orange"]; return [__("Past Due Date"), "orange"];
} else if(doc.status === 'Unpaid') { } else if(doc.status === 'Unpaid') {

View File

@ -7,7 +7,7 @@ import unittest
import frappe import frappe
from erpnext.accounts.doctype.subscription.subscription import get_prorata_factor from erpnext.accounts.doctype.subscription.subscription import get_prorata_factor
from frappe.utils.data import nowdate, add_days, add_to_date, add_months, date_diff, flt from frappe.utils.data import nowdate, add_days, add_to_date, add_months, date_diff, flt, get_date_str
def create_plan(): def create_plan():
@ -15,7 +15,7 @@ def create_plan():
plan = frappe.new_doc('Subscription Plan') plan = frappe.new_doc('Subscription Plan')
plan.plan_name = '_Test Plan Name' plan.plan_name = '_Test Plan Name'
plan.item = '_Test Non Stock Item' plan.item = '_Test Non Stock Item'
plan.price_determination = "Fixed rate" plan.price_determination = "Fixed Rate"
plan.cost = 900 plan.cost = 900
plan.billing_interval = 'Month' plan.billing_interval = 'Month'
plan.billing_interval_count = 1 plan.billing_interval_count = 1
@ -25,7 +25,7 @@ def create_plan():
plan = frappe.new_doc('Subscription Plan') plan = frappe.new_doc('Subscription Plan')
plan.plan_name = '_Test Plan Name 2' plan.plan_name = '_Test Plan Name 2'
plan.item = '_Test Non Stock Item' plan.item = '_Test Non Stock Item'
plan.price_determination = "Fixed rate" plan.price_determination = "Fixed Rate"
plan.cost = 1999 plan.cost = 1999
plan.billing_interval = 'Month' plan.billing_interval = 'Month'
plan.billing_interval_count = 1 plan.billing_interval_count = 1
@ -35,12 +35,29 @@ def create_plan():
plan = frappe.new_doc('Subscription Plan') plan = frappe.new_doc('Subscription Plan')
plan.plan_name = '_Test Plan Name 3' plan.plan_name = '_Test Plan Name 3'
plan.item = '_Test Non Stock Item' plan.item = '_Test Non Stock Item'
plan.price_determination = "Fixed rate" plan.price_determination = "Fixed Rate"
plan.cost = 1999 plan.cost = 1999
plan.billing_interval = 'Day' plan.billing_interval = 'Day'
plan.billing_interval_count = 14 plan.billing_interval_count = 14
plan.insert() plan.insert()
# Defined a quarterly Subscription Plan
if not frappe.db.exists('Subscription Plan', '_Test Plan Name 4'):
plan = frappe.new_doc('Subscription Plan')
plan.plan_name = '_Test Plan Name 4'
plan.item = '_Test Non Stock Item'
plan.price_determination = "Monthly Rate"
plan.cost = 20000
plan.billing_interval = 'Month'
plan.billing_interval_count = 3
plan.insert()
if not frappe.db.exists('Supplier', '_Test Supplier'):
supplier = frappe.new_doc('Supplier')
supplier.supplier_name = '_Test Supplier'
supplier.supplier_group = 'All Supplier Groups'
supplier.insert()
class TestSubscription(unittest.TestCase): class TestSubscription(unittest.TestCase):
def setUp(self): def setUp(self):
@ -48,7 +65,8 @@ class TestSubscription(unittest.TestCase):
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')
subscription.customer = '_Test Customer' subscription.party_type = 'Customer'
subscription.party = '_Test Customer'
subscription.trial_period_start = nowdate() subscription.trial_period_start = nowdate()
subscription.trial_period_end = add_days(nowdate(), 30) subscription.trial_period_end = add_days(nowdate(), 30)
subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1}) subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1})
@ -56,8 +74,8 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.trial_period_start, nowdate()) self.assertEqual(subscription.trial_period_start, nowdate())
self.assertEqual(subscription.trial_period_end, add_days(nowdate(), 30)) self.assertEqual(subscription.trial_period_end, add_days(nowdate(), 30))
self.assertEqual(subscription.trial_period_start, subscription.current_invoice_start) self.assertEqual(add_days(subscription.trial_period_end, 1), get_date_str(subscription.current_invoice_start))
self.assertEqual(subscription.trial_period_end, subscription.current_invoice_end) self.assertEqual(add_days(subscription.current_invoice_start, 30), get_date_str(subscription.current_invoice_end))
self.assertEqual(subscription.invoices, []) self.assertEqual(subscription.invoices, [])
self.assertEqual(subscription.status, 'Trialling') self.assertEqual(subscription.status, 'Trialling')
@ -65,7 +83,8 @@ class TestSubscription(unittest.TestCase):
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.customer = '_Test Customer' subscription.party_type = 'Customer'
subscription.party = '_Test Customer'
subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1}) subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1})
subscription.save() subscription.save()
@ -81,7 +100,8 @@ class TestSubscription(unittest.TestCase):
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.customer = '_Test Customer' subscription.party_type = 'Customer'
subscription.party = '_Test Customer'
subscription.trial_period_end = nowdate() subscription.trial_period_end = nowdate()
subscription.trial_period_start = add_days(nowdate(), 30) subscription.trial_period_start = add_days(nowdate(), 30)
subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1}) subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1})
@ -91,7 +111,8 @@ class TestSubscription(unittest.TestCase):
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')
subscription.customer = '_Test Customer' subscription.party_type = 'Customer'
subscription.party = '_Test Customer'
subscription.trial_period_end = nowdate() subscription.trial_period_end = nowdate()
subscription.trial_period_start = add_days(nowdate(), 30) subscription.trial_period_start = add_days(nowdate(), 30)
subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1}) subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1})
@ -102,8 +123,9 @@ class TestSubscription(unittest.TestCase):
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')
subscription.customer = '_Test Customer' subscription.party_type = 'Customer'
subscription.start = '2018-01-01' subscription.party = '_Test Customer'
subscription.start_date = '2018-01-01'
subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1}) subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1})
subscription.insert() subscription.insert()
@ -114,18 +136,22 @@ class TestSubscription(unittest.TestCase):
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-01-01')
self.assertEqual(subscription.status, 'Past Due Date') subscription.process()
self.assertEqual(subscription.status, 'Unpaid')
subscription.delete() 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')
subscription.customer = '_Test Customer' subscription.party_type = '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 = '2018-01-01' subscription.start_date = '2018-01-01'
subscription.insert() subscription.insert()
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, 'Past Due Date')
# Status is unpaid as Days until Due is zero and grace period is Zero
self.assertEqual(subscription.status, 'Unpaid')
subscription.get_current_invoice() subscription.get_current_invoice()
current_invoice = subscription.get_current_invoice() current_invoice = subscription.get_current_invoice()
@ -137,7 +163,7 @@ class TestSubscription(unittest.TestCase):
subscription.process() subscription.process()
self.assertEqual(subscription.status, 'Active') self.assertEqual(subscription.status, 'Active')
self.assertEqual(subscription.current_invoice_start, add_months(subscription.start, 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() subscription.delete()
@ -149,16 +175,17 @@ class TestSubscription(unittest.TestCase):
settings.save() settings.save()
subscription = frappe.new_doc('Subscription') subscription = frappe.new_doc('Subscription')
subscription.customer = '_Test Customer' subscription.party_type = '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 = '2018-01-01' subscription.start_date = '2018-01-01'
subscription.insert() subscription.insert()
self.assertEqual(subscription.status, 'Active')
subscription.process() # generate first invoice subscription.process() # generate first invoice
self.assertEqual(subscription.status, 'Past Due Date')
subscription.process()
# 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
self.assertEqual(subscription.status, 'Cancelled') self.assertEqual(subscription.status, 'Cancelled')
settings.cancel_after_grace = default_grace_period_action settings.cancel_after_grace = default_grace_period_action
@ -172,16 +199,14 @@ class TestSubscription(unittest.TestCase):
settings.save() settings.save()
subscription = frappe.new_doc('Subscription') subscription = frappe.new_doc('Subscription')
subscription.customer = '_Test Customer' subscription.party_type = '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 = '2018-01-01' subscription.start_date = '2018-01-01'
subscription.insert() subscription.insert()
subscription.process() # generate first invoice subscription.process() # generate first invoice
self.assertEqual(subscription.status, 'Past Due Date') # Status is unpaid as Days until Due is zero and grace period is Zero
subscription.process()
# This should change status to Cancelled since grace period is 0
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
@ -190,10 +215,11 @@ class TestSubscription(unittest.TestCase):
def test_subscription_invoice_days_until_due(self): def test_subscription_invoice_days_until_due(self):
subscription = frappe.new_doc('Subscription') subscription = frappe.new_doc('Subscription')
subscription.customer = '_Test Customer' subscription.party_type = 'Customer'
subscription.party = '_Test Customer'
subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1}) subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1})
subscription.days_until_due = 10 subscription.days_until_due = 10
subscription.start = add_months(nowdate(), -1) subscription.start_date = add_months(nowdate(), -1)
subscription.insert() subscription.insert()
subscription.process() # generate first invoice subscription.process() # generate first invoice
self.assertEqual(len(subscription.invoices), 1) self.assertEqual(len(subscription.invoices), 1)
@ -208,9 +234,10 @@ class TestSubscription(unittest.TestCase):
settings.save() settings.save()
subscription = frappe.new_doc('Subscription') subscription = frappe.new_doc('Subscription')
subscription.customer = '_Test Customer' subscription.party_type = '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 = '2018-01-01' subscription.start_date = '2018-01-01'
subscription.insert() subscription.insert()
subscription.process() # generate first invoice subscription.process() # generate first invoice
@ -232,7 +259,8 @@ class TestSubscription(unittest.TestCase):
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')
subscription.customer = '_Test Customer' subscription.party_type = 'Customer'
subscription.party = '_Test Customer'
subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1}) subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1})
subscription.save() subscription.save()
subscription.process() # no changes expected subscription.process() # no changes expected
@ -258,7 +286,8 @@ class TestSubscription(unittest.TestCase):
def test_subscription_cancelation(self): def test_subscription_cancelation(self):
subscription = frappe.new_doc('Subscription') subscription = frappe.new_doc('Subscription')
subscription.customer = '_Test Customer' subscription.party_type = 'Customer'
subscription.party = '_Test Customer'
subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1}) subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1})
subscription.save() subscription.save()
subscription.cancel_subscription() subscription.cancel_subscription()
@ -274,7 +303,8 @@ class TestSubscription(unittest.TestCase):
settings.save() settings.save()
subscription = frappe.new_doc('Subscription') subscription = frappe.new_doc('Subscription')
subscription.customer = '_Test Customer' subscription.party_type = 'Customer'
subscription.party = '_Test Customer'
subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1}) subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1})
subscription.save() subscription.save()
@ -309,7 +339,8 @@ class TestSubscription(unittest.TestCase):
settings.save() settings.save()
subscription = frappe.new_doc('Subscription') subscription = frappe.new_doc('Subscription')
subscription.customer = '_Test Customer' subscription.party_type = 'Customer'
subscription.party = '_Test Customer'
subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1}) subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1})
subscription.save() subscription.save()
subscription.cancel_subscription() subscription.cancel_subscription()
@ -329,7 +360,8 @@ class TestSubscription(unittest.TestCase):
settings.save() settings.save()
subscription = frappe.new_doc('Subscription') subscription = frappe.new_doc('Subscription')
subscription.customer = '_Test Customer' subscription.party_type = 'Customer'
subscription.party = '_Test Customer'
subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1}) subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1})
subscription.save() subscription.save()
subscription.cancel_subscription() subscription.cancel_subscription()
@ -353,16 +385,14 @@ class TestSubscription(unittest.TestCase):
settings.save() settings.save()
subscription = frappe.new_doc('Subscription') subscription = frappe.new_doc('Subscription')
subscription.customer = '_Test Customer' subscription.party_type = '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 = '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) invoices = len(subscription.invoices)
self.assertEqual(subscription.status, 'Past Due Date')
self.assertEqual(len(subscription.invoices), invoices)
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), invoices)
@ -387,15 +417,14 @@ class TestSubscription(unittest.TestCase):
settings.save() settings.save()
subscription = frappe.new_doc('Subscription') subscription = frappe.new_doc('Subscription')
subscription.customer = '_Test Customer' subscription.party_type = '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 = '2018-01-01' subscription.start_date = '2018-01-01'
subscription.insert() subscription.insert()
subscription.process() # generate first invoice subscription.process() # generate first invoice
self.assertEqual(subscription.status, 'Past Due Date') # Status is unpaid as Days until Due is zero and grace period is Zero
subscription.process()
self.assertEqual(subscription.status, 'Unpaid') self.assertEqual(subscription.status, 'Unpaid')
subscription.cancel_subscription() subscription.cancel_subscription()
@ -424,16 +453,14 @@ class TestSubscription(unittest.TestCase):
settings.save() settings.save()
subscription = frappe.new_doc('Subscription') subscription = frappe.new_doc('Subscription')
subscription.customer = '_Test Customer' subscription.party_type = '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 = '2018-01-01' subscription.start_date = '2018-01-01'
subscription.insert() subscription.insert()
subscription.process() # generate first invoice subscription.process() # generate first invoice
# This should change status to Unpaid since grace period is 0
self.assertEqual(subscription.status, 'Past Due Date')
subscription.process()
# This should change status to Cancelled since grace period is 0
self.assertEqual(subscription.status, 'Unpaid') self.assertEqual(subscription.status, 'Unpaid')
invoice = subscription.get_current_invoice() invoice = subscription.get_current_invoice()
@ -445,7 +472,7 @@ class TestSubscription(unittest.TestCase):
# A new invoice is generated # A new invoice is generated
subscription.process() subscription.process()
self.assertEqual(subscription.status, 'Past Due Date') 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()
@ -453,7 +480,8 @@ class TestSubscription(unittest.TestCase):
def test_restart_active_subscription(self): def test_restart_active_subscription(self):
subscription = frappe.new_doc('Subscription') subscription = frappe.new_doc('Subscription')
subscription.customer = '_Test Customer' subscription.party_type = 'Customer'
subscription.party = '_Test Customer'
subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1}) subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1})
subscription.save() subscription.save()
@ -463,7 +491,8 @@ class TestSubscription(unittest.TestCase):
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.customer = '_Test Customer' subscription.party_type = 'Customer'
subscription.party = '_Test Customer'
subscription.additional_discount_percentage = 10 subscription.additional_discount_percentage = 10
subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1}) subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1})
subscription.save() subscription.save()
@ -478,7 +507,8 @@ class TestSubscription(unittest.TestCase):
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.customer = '_Test Customer' subscription.party_type = 'Customer'
subscription.party = '_Test Customer'
subscription.additional_discount_amount = 11 subscription.additional_discount_amount = 11
subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1}) subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1})
subscription.save() subscription.save()
@ -495,7 +525,8 @@ class TestSubscription(unittest.TestCase):
# Create a non pre-billed subscription, processing should not create # Create a non pre-billed subscription, processing should not create
# invoices. # invoices.
subscription = frappe.new_doc('Subscription') subscription = frappe.new_doc('Subscription')
subscription.customer = '_Test Customer' subscription.party_type = 'Customer'
subscription.party = '_Test Customer'
subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1}) subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1})
subscription.save() subscription.save()
subscription.process() subscription.process()
@ -517,10 +548,12 @@ class TestSubscription(unittest.TestCase):
settings.save() settings.save()
subscription = frappe.new_doc('Subscription') subscription = frappe.new_doc('Subscription')
subscription.customer = '_Test Customer' subscription.party_type = 'Customer'
subscription.party = '_Test Customer'
subscription.generate_invoice_at_period_start = True subscription.generate_invoice_at_period_start = True
subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1}) subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1})
subscription.save() subscription.save()
subscription.process()
subscription.cancel_subscription() subscription.cancel_subscription()
self.assertEqual(len(subscription.invoices), 1) self.assertEqual(len(subscription.invoices), 1)
@ -538,3 +571,65 @@ class TestSubscription(unittest.TestCase):
settings.save() settings.save()
subscription.delete() subscription.delete()
def test_subscription_with_follow_calendar_months(self):
subscription = frappe.new_doc('Subscription')
subscription.party_type = 'Supplier'
subscription.party = '_Test Supplier'
subscription.generate_invoice_at_period_start = 1
subscription.follow_calendar_months = 1
# 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'
self.assertEqual(get_date_str(subscription.current_invoice_end), '2018-03-31')
def test_subscription_generate_invoice_past_due(self):
subscription = frappe.new_doc('Subscription')
subscription.party_type = 'Supplier'
subscription.party = '_Test Supplier'
subscription.generate_invoice_at_period_start = 1
subscription.generate_new_invoices_past_due_date = 1
# select subscription start date as '2018-01-15'
subscription.start_date = '2018-01-01'
subscription.append('plans', {'plan': '_Test Plan Name 4', 'qty': 1})
subscription.save()
# Process subscription and create first invoice
# Subscription status will be unpaid since due date has already passed
subscription.process()
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, 'Unpaid')
# Now the Subscription is unpaid
# Even then new invoice should be created as we have enabled `generate_new_invoices_past_due_date` in
# subscription
subscription.process()
self.assertEqual(len(subscription.invoices), 2)
def test_subscription_without_generate_invoice_past_due(self):
subscription = frappe.new_doc('Subscription')
subscription.party_type = 'Supplier'
subscription.party = '_Test Supplier'
subscription.generate_invoice_at_period_start = 1
# select subscription start date as '2018-01-15'
subscription.start_date = '2018-01-01'
subscription.append('plans', {'plan': '_Test Plan Name 4', 'qty': 1})
subscription.save()
# Process subscription and create first invoice
# Subscription status will be unpaid since due date has already passed
subscription.process()
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, 'Unpaid')
subscription.process()
self.assertEqual(len(subscription.invoices), 1)

View File

@ -1,73 +1,40 @@
{ {
"allow_copy": 0, "actions": [],
"allow_guest_to_view": 0, "creation": "2018-02-26 04:21:41.265055",
"allow_import": 0, "doctype": "DocType",
"allow_rename": 0, "editable_grid": 1,
"beta": 0, "engine": "InnoDB",
"creation": "2018-02-26 04:21:41.265055", "field_order": [
"custom": 0, "document_type",
"docstatus": 0, "invoice"
"doctype": "DocType", ],
"document_type": "",
"editable_grid": 1,
"engine": "InnoDB",
"fields": [ "fields": [
{ {
"allow_bulk_edit": 0, "fieldname": "document_type",
"allow_on_submit": 0, "fieldtype": "Link",
"bold": 0, "label": "Document Type ",
"collapsible": 0, "options": "DocType",
"columns": 0, "read_only": 1
"fieldname": "invoice", },
"fieldtype": "Link", {
"hidden": 0, "fieldname": "invoice",
"ignore_user_permissions": 0, "fieldtype": "Dynamic Link",
"ignore_xss_filter": 0, "in_list_view": 1,
"in_filter": 0, "label": "Invoice",
"in_global_search": 0, "options": "document_type",
"in_list_view": 1, "read_only": 1
"in_standard_filter": 0,
"label": "Invoice",
"length": 0,
"no_copy": 0,
"options": "Sales Invoice",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
} }
], ],
"has_web_view": 0, "istable": 1,
"hide_heading": 0, "links": [],
"hide_toolbar": 0, "modified": "2020-06-01 22:23:54.462718",
"idx": 0, "modified_by": "Administrator",
"image_view": 0, "module": "Accounts",
"in_create": 0, "name": "Subscription Invoice",
"is_submittable": 0, "owner": "Administrator",
"issingle": 0, "permissions": [],
"istable": 1, "quick_entry": 1,
"max_attachments": 0, "sort_field": "modified",
"modified": "2018-02-26 10:48:07.033422", "sort_order": "DESC",
"modified_by": "Administrator", "track_changes": 1
"module": "Accounts",
"name": "Subscription Invoice",
"name_case": "",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1,
"track_seen": 0
} }

View File

@ -1,4 +1,5 @@
{ {
"actions": [],
"allow_rename": 1, "allow_rename": 1,
"autoname": "field:plan_name", "autoname": "field:plan_name",
"creation": "2018-02-24 11:31:23.066506", "creation": "2018-02-24 11:31:23.066506",
@ -24,6 +25,7 @@
"column_break_16", "column_break_16",
"payment_gateway", "payment_gateway",
"accounting_dimensions_section", "accounting_dimensions_section",
"cost_center",
"dimension_col_break" "dimension_col_break"
], ],
"fields": [ "fields": [
@ -60,8 +62,8 @@
{ {
"fieldname": "price_determination", "fieldname": "price_determination",
"fieldtype": "Select", "fieldtype": "Select",
"label": "Price Determination", "label": "Subscription Price Based On",
"options": "\nFixed rate\nBased on price list", "options": "\nFixed Rate\nBased On Price List\nMonthly Rate",
"reqd": 1 "reqd": 1
}, },
{ {
@ -69,7 +71,7 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"depends_on": "eval:doc.price_determination==\"Fixed rate\"", "depends_on": "eval:['Fixed Rate', 'Monthly Rate'].includes(doc.price_determination)",
"fieldname": "cost", "fieldname": "cost",
"fieldtype": "Currency", "fieldtype": "Currency",
"in_list_view": 1, "in_list_view": 1,
@ -136,9 +138,16 @@
{ {
"fieldname": "dimension_col_break", "fieldname": "dimension_col_break",
"fieldtype": "Column Break" "fieldtype": "Column Break"
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
} }
], ],
"modified": "2019-07-25 18:35:04.362556", "links": [],
"modified": "2020-06-25 10:53:44.205774",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Subscription Plan", "name": "Subscription Plan",
@ -155,6 +164,30 @@
"role": "System Manager", "role": "System Manager",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts User",
"share": 1,
"write": 1
} }
], ],
"sort_field": "modified", "sort_field": "modified",

View File

@ -5,6 +5,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import frappe import frappe
from frappe import _ from frappe import _
from frappe.utils import get_first_day, get_last_day, date_diff, flt, getdate
from frappe.model.document import Document from frappe.model.document import Document
from erpnext.utilities.product import get_price from erpnext.utilities.product import get_price
@ -17,12 +18,12 @@ class SubscriptionPlan(Document):
frappe.throw(_('Billing Interval Count cannot be less than 1')) frappe.throw(_('Billing Interval Count cannot be less than 1'))
@frappe.whitelist() @frappe.whitelist()
def get_plan_rate(plan, quantity=1, customer=None): def get_plan_rate(plan, quantity=1, customer=None, start_date=None, end_date=None, prorate_factor=1):
plan = frappe.get_doc("Subscription Plan", plan) plan = frappe.get_doc("Subscription Plan", plan)
if plan.price_determination == "Fixed rate": if plan.price_determination == "Fixed Rate":
return plan.cost return plan.cost * prorate_factor
elif plan.price_determination == "Based on price list": elif plan.price_determination == "Based On Price List":
if customer: if customer:
customer_group = frappe.db.get_value("Customer", customer, "customer_group") customer_group = frappe.db.get_value("Customer", customer, "customer_group")
else: else:
@ -32,4 +33,25 @@ def get_plan_rate(plan, quantity=1, customer=None):
if not price: if not price:
return 0 return 0
else: else:
return price.price_list_rate return price.price_list_rate * prorate_factor
elif plan.price_determination == 'Monthly Rate':
start_date = getdate(start_date)
end_date = getdate(end_date)
no_of_months = (end_date.year - start_date.year) * 12 + (end_date.month - start_date.month) + 1
cost = plan.cost * no_of_months
# Adjust cost if start or end date is not month start or end
prorate = frappe.db.get_single_value('Subscription Settings', 'prorate')
if prorate:
prorate_factor = flt(date_diff(start_date, get_first_day(start_date)) / date_diff(
get_last_day(start_date), get_first_day(start_date)), 1)
prorate_factor += flt(date_diff(get_last_day(end_date), end_date) / date_diff(
get_last_day(end_date), get_first_day(end_date)), 1)
cost -= (plan.cost * prorate_factor)
return cost

View File

@ -1,106 +1,40 @@
{ {
"allow_copy": 0, "actions": [],
"allow_guest_to_view": 0, "creation": "2018-02-25 07:35:07.736146",
"allow_import": 0, "doctype": "DocType",
"allow_rename": 0, "editable_grid": 1,
"beta": 0, "engine": "InnoDB",
"creation": "2018-02-25 07:35:07.736146", "field_order": [
"custom": 0, "plan",
"docstatus": 0, "qty"
"doctype": "DocType", ],
"document_type": "",
"editable_grid": 1,
"engine": "InnoDB",
"fields": [ "fields": [
{ {
"allow_bulk_edit": 0, "fieldname": "qty",
"allow_in_quick_entry": 0, "fieldtype": "Int",
"allow_on_submit": 0, "in_list_view": 1,
"bold": 0, "label": "Quantity",
"collapsible": 0, "reqd": 1
"columns": 0, },
"fieldname": "qty",
"fieldtype": "Int",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Quantity",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{ {
"allow_bulk_edit": 0, "fieldname": "plan",
"allow_in_quick_entry": 0, "fieldtype": "Link",
"allow_on_submit": 0, "in_list_view": 1,
"bold": 0, "label": "Plan",
"collapsible": 0, "options": "Subscription Plan",
"columns": 0, "reqd": 1
"fieldname": "plan",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Plan",
"length": 0,
"no_copy": 0,
"options": "Subscription Plan",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
} }
], ],
"has_web_view": 0, "istable": 1,
"hide_heading": 0, "links": [],
"hide_toolbar": 0, "modified": "2020-06-14 17:44:05.275100",
"idx": 0, "modified_by": "Administrator",
"image_view": 0, "module": "Accounts",
"in_create": 0, "name": "Subscription Plan Detail",
"is_submittable": 0, "owner": "Administrator",
"issingle": 0, "permissions": [],
"istable": 1, "quick_entry": 1,
"max_attachments": 0, "sort_field": "modified",
"modified": "2018-06-20 15:35:13.514699", "sort_order": "DESC",
"modified_by": "Administrator", "track_changes": 1
"module": "Accounts",
"name": "Subscription Plan Detail",
"name_case": "",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1,
"track_seen": 0
} }

View File

@ -1,179 +1,76 @@
{ {
"allow_copy": 0, "actions": [],
"allow_guest_to_view": 0, "creation": "2018-02-26 06:13:37.910139",
"allow_import": 0, "doctype": "DocType",
"allow_rename": 0, "editable_grid": 1,
"beta": 0, "engine": "InnoDB",
"creation": "2018-02-26 06:13:37.910139", "field_order": [
"custom": 0, "grace_period",
"docstatus": 0, "cancel_after_grace",
"doctype": "DocType", "prorate"
"document_type": "", ],
"editable_grid": 1,
"engine": "InnoDB",
"fields": [ "fields": [
{ {
"allow_bulk_edit": 0, "default": "1",
"allow_on_submit": 0, "description": "Number of days after invoice date has elapsed before canceling subscription or marking subscription as unpaid",
"bold": 0, "fieldname": "grace_period",
"collapsible": 0, "fieldtype": "Int",
"columns": 0, "label": "Grace Period"
"default": "1", },
"description": "Number of days after invoice date has elapsed before canceling subscription or marking subscription as unpaid",
"fieldname": "grace_period",
"fieldtype": "Int",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Grace Period",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{ {
"allow_bulk_edit": 0, "default": "0",
"allow_on_submit": 0, "fieldname": "cancel_after_grace",
"bold": 0, "fieldtype": "Check",
"collapsible": 0, "label": "Cancel Subscription After Grace Period"
"columns": 0, },
"default": "0",
"fieldname": "cancel_after_grace",
"fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Cancel Invoice After Grace Period",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{ {
"allow_bulk_edit": 0, "default": "1",
"allow_on_submit": 0, "fieldname": "prorate",
"bold": 0, "fieldtype": "Check",
"collapsible": 0, "label": "Prorate"
"columns": 0,
"default": "1",
"fieldname": "prorate",
"fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Prorate",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
} }
], ],
"has_web_view": 0, "issingle": 1,
"hide_heading": 0, "links": [],
"hide_toolbar": 0, "modified": "2020-06-23 09:13:44.292792",
"idx": 0, "modified_by": "Administrator",
"image_view": 0, "module": "Accounts",
"in_create": 0, "name": "Subscription Settings",
"is_submittable": 0, "owner": "Administrator",
"issingle": 1,
"istable": 0,
"max_attachments": 0,
"modified": "2018-02-26 13:58:09.455832",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Subscription Settings",
"name_case": "",
"owner": "Administrator",
"permissions": [ "permissions": [
{ {
"amend": 0, "create": 1,
"apply_user_permissions": 0, "delete": 1,
"cancel": 0, "email": 1,
"create": 1, "print": 1,
"delete": 1, "read": 1,
"email": 1, "role": "System Manager",
"export": 0, "share": 1,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 0,
"role": "System Manager",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"write": 1 "write": 1
}, },
{ {
"amend": 0, "create": 1,
"apply_user_permissions": 0, "delete": 1,
"cancel": 0, "email": 1,
"create": 1, "print": 1,
"delete": 1, "read": 1,
"email": 1, "role": "Accounts Manager",
"export": 0, "share": 1,
"if_owner": 0, "write": 1
"import": 0, },
"permlevel": 0, {
"print": 1, "create": 1,
"read": 1, "delete": 1,
"report": 0, "email": 1,
"role": "Administrator", "print": 1,
"set_user_permissions": 0, "read": 1,
"share": 1, "role": "Accounts User",
"submit": 0, "share": 1,
"write": 1 "write": 1
} }
], ],
"quick_entry": 1, "quick_entry": 1,
"read_only": 0, "sort_field": "modified",
"read_only_onload": 0, "sort_order": "DESC",
"show_name_in_global_search": 0, "track_changes": 1
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1,
"track_seen": 0
} }

View File

@ -697,6 +697,7 @@ execute:frappe.rename_doc("Desk Page", "Loan Management", "Loan", force=True)
erpnext.patches.v12_0.update_uom_conversion_factor erpnext.patches.v12_0.update_uom_conversion_factor
erpnext.patches.v13_0.delete_old_purchase_reports erpnext.patches.v13_0.delete_old_purchase_reports
erpnext.patches.v12_0.set_italian_import_supplier_invoice_permissions erpnext.patches.v12_0.set_italian_import_supplier_invoice_permissions
erpnext.patches.v13_0.update_subscription
erpnext.patches.v12_0.unhide_cost_center_field erpnext.patches.v12_0.unhide_cost_center_field
erpnext.patches.v13_0.update_sla_enhancements erpnext.patches.v13_0.update_sla_enhancements
erpnext.patches.v12_0.update_address_template_for_india erpnext.patches.v12_0.update_address_template_for_india

View File

@ -0,0 +1,41 @@
# Copyright (c) 2019, Frappe and Contributors
# License: GNU General Public License v3. See license.txt
from __future__ import unicode_literals
import frappe
from six import iteritems
def execute():
frappe.reload_doc('accounts', 'doctype', 'subscription')
frappe.reload_doc('accounts', 'doctype', 'subscription_invoice')
frappe.reload_doc('accounts', 'doctype', 'subscription_plan')
if frappe.db.has_column('Subscription', 'customer'):
frappe.db.sql("""
UPDATE `tabSubscription`
SET
start_date = start,
party_type = 'Customer',
party = customer,
sales_tax_template = tax_template
WHERE IFNULL(party,'') = ''
""")
frappe.db.sql("""
UPDATE `tabSubscription Invoice`
SET document_type = 'Sales Invoice'
WHERE IFNULL(document_type, '') = ''
""")
price_determination_map = {
'Fixed rate': 'Fixed Rate',
'Based on price list': 'Based On Price List'
}
for key, value in iteritems(price_determination_map):
frappe.db.sql("""
UPDATE `tabSubscription Plan`
SET price_determination = %s
WHERE price_determination = %s
""", (value, key))