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>
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
index d8759e9..0599e19 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
@@ -167,6 +167,7 @@
"column_break_63",
"unrealized_profit_loss_account",
"subscription_section",
+ "subscription",
"auto_repeat",
"update_auto_repeat_reference",
"column_break_114",
@@ -1424,6 +1425,12 @@
"read_only": 1
},
{
+ "fieldname": "subscription",
+ "fieldtype": "Link",
+ "label": "Subscription",
+ "options": "Subscription"
+ },
+ {
"default": "0",
"fieldname": "is_old_subcontracting_flow",
"fieldtype": "Check",
@@ -1577,7 +1584,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
- "modified": "2023-07-04 17:22:59.145031",
+ "modified": "2023-07-25 17:22:59.145031",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
index f0d3f72..7581366 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
@@ -194,6 +194,7 @@
"select_print_heading",
"language",
"subscription_section",
+ "subscription",
"from_date",
"auto_repeat",
"column_break_140",
@@ -2018,6 +2019,12 @@
"read_only": 1
},
{
+ "fieldname": "subscription",
+ "fieldtype": "Link",
+ "label": "Subscription",
+ "options": "Subscription"
+ },
+ {
"default": "0",
"depends_on": "eval: doc.apply_discount_on == \"Grand Total\"",
"fieldname": "is_cash_or_non_trade_discount",
@@ -2157,7 +2164,7 @@
"link_fieldname": "consolidated_invoice"
}
],
- "modified": "2023-06-21 16:02:18.988799",
+ "modified": "2023-07-25 16:02:18.988799",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",
diff --git a/erpnext/accounts/doctype/subscription/subscription.js b/erpnext/accounts/doctype/subscription/subscription.js
index 1a90664..ae789b5 100644
--- a/erpnext/accounts/doctype/subscription/subscription.js
+++ b/erpnext/accounts/doctype/subscription/subscription.js
@@ -2,16 +2,16 @@
// For license information, please see license.txt
frappe.ui.form.on('Subscription', {
- setup: function(frm) {
- frm.set_query('party_type', function() {
+ setup: function (frm) {
+ frm.set_query('party_type', function () {
return {
- filters : {
+ filters: {
name: ['in', ['Customer', 'Supplier']]
}
}
});
- frm.set_query('cost_center', function() {
+ frm.set_query('cost_center', function () {
return {
filters: {
company: frm.doc.company
@@ -20,76 +20,60 @@
});
},
- refresh: function(frm) {
- if(!frm.is_new()){
- if(frm.doc.status !== 'Cancelled'){
- frm.add_custom_button(
- __('Cancel Subscription'),
- () => frm.events.cancel_this_subscription(frm)
- );
- frm.add_custom_button(
- __('Fetch Subscription Updates'),
- () => frm.events.get_subscription_updates(frm)
- );
- }
- else if(frm.doc.status === 'Cancelled'){
- frm.add_custom_button(
- __('Restart Subscription'),
- () => frm.events.renew_this_subscription(frm)
- );
- }
+ refresh: function (frm) {
+ if (frm.is_new()) return;
+
+ if (frm.doc.status !== 'Cancelled') {
+ frm.add_custom_button(
+ __('Fetch Subscription Updates'),
+ () => frm.trigger('get_subscription_updates'),
+ __('Actions')
+ );
+
+ frm.add_custom_button(
+ __('Cancel Subscription'),
+ () => frm.trigger('cancel_this_subscription'),
+ __('Actions')
+ );
+ } else if (frm.doc.status === 'Cancelled') {
+ frm.add_custom_button(
+ __('Restart Subscription'),
+ () => frm.trigger('renew_this_subscription'),
+ __('Actions')
+ );
}
},
- cancel_this_subscription: function(frm) {
- const doc = frm.doc;
+ cancel_this_subscription: function (frm) {
frappe.confirm(
__('This action will stop future billing. Are you sure you want to cancel this subscription?'),
- function() {
- frappe.call({
- method:
- "erpnext.accounts.doctype.subscription.subscription.cancel_subscription",
- args: {name: doc.name},
- callback: function(data){
- if(!data.exc){
- frm.reload_doc();
- }
+ () => {
+ frm.call('cancel_subscription').then(r => {
+ if (!r.exec) {
+ frm.reload_doc();
}
});
}
);
},
- renew_this_subscription: function(frm) {
- const doc = frm.doc;
+ renew_this_subscription: function (frm) {
frappe.confirm(
- __('You will lose records of previously generated invoices. Are you sure you want to restart this subscription?'),
- function() {
- frappe.call({
- method:
- "erpnext.accounts.doctype.subscription.subscription.restart_subscription",
- args: {name: doc.name},
- callback: function(data){
- if(!data.exc){
- frm.reload_doc();
- }
+ __('Are you sure you want to restart this subscription?'),
+ () => {
+ frm.call('restart_subscription').then(r => {
+ if (!r.exec) {
+ frm.reload_doc();
}
});
}
);
},
- get_subscription_updates: function(frm) {
- const doc = frm.doc;
- frappe.call({
- method:
- "erpnext.accounts.doctype.subscription.subscription.get_subscription_updates",
- args: {name: doc.name},
- freeze: true,
- callback: function(data){
- if(!data.exc){
- frm.reload_doc();
- }
+ get_subscription_updates: function (frm) {
+ frm.call('process').then(r => {
+ if (!r.exec) {
+ frm.reload_doc();
}
});
}
diff --git a/erpnext/accounts/doctype/subscription/subscription.json b/erpnext/accounts/doctype/subscription/subscription.json
index c4e4be7..c15aa1e 100644
--- a/erpnext/accounts/doctype/subscription/subscription.json
+++ b/erpnext/accounts/doctype/subscription/subscription.json
@@ -19,6 +19,7 @@
"trial_period_end",
"follow_calendar_months",
"generate_new_invoices_past_due_date",
+ "submit_invoice",
"column_break_11",
"current_invoice_start",
"current_invoice_end",
@@ -35,12 +36,8 @@
"cb_2",
"additional_discount_percentage",
"additional_discount_amount",
- "sb_3",
- "submit_invoice",
- "invoices",
"accounting_dimensions_section",
- "cost_center",
- "dimension_col_break"
+ "cost_center"
],
"fields": [
{
@@ -163,29 +160,12 @@
"label": "Additional DIscount Amount"
},
{
- "depends_on": "eval:doc.invoices",
- "fieldname": "sb_3",
- "fieldtype": "Section Break",
- "label": "Invoices"
- },
- {
- "collapsible": 1,
- "fieldname": "invoices",
- "fieldtype": "Table",
- "label": "Invoices",
- "options": "Subscription Invoice"
- },
- {
"collapsible": 1,
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
},
{
- "fieldname": "dimension_col_break",
- "fieldtype": "Column Break"
- },
- {
"fieldname": "party_type",
"fieldtype": "Link",
"label": "Party Type",
@@ -259,15 +239,27 @@
"default": "1",
"fieldname": "submit_invoice",
"fieldtype": "Check",
- "label": "Submit Invoice Automatically"
+ "label": "Submit Generated Invoices"
}
],
"index_web_pages_for_search": 1,
- "links": [],
- "modified": "2021-04-19 15:24:27.550797",
+ "links": [
+ {
+ "group": "Buying",
+ "link_doctype": "Purchase Invoice",
+ "link_fieldname": "subscription"
+ },
+ {
+ "group": "Selling",
+ "link_doctype": "Sales Invoice",
+ "link_fieldname": "subscription"
+ }
+ ],
+ "modified": "2022-02-18 23:24:57.185054",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Subscription",
+ "naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@@ -309,5 +301,6 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py
index 8708342..bbcade1 100644
--- a/erpnext/accounts/doctype/subscription/subscription.py
+++ b/erpnext/accounts/doctype/subscription/subscription.py
@@ -2,14 +2,17 @@
# For license information, please see license.txt
+from datetime import datetime
+from typing import Dict, List, Optional, Union
+
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils.data import (
add_days,
+ add_months,
add_to_date,
cint,
- cstr,
date_diff,
flt,
get_last_day,
@@ -17,8 +20,7 @@
nowdate,
)
-import erpnext
-from erpnext import get_default_company
+from erpnext import get_default_company, get_default_cost_center
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
@@ -26,33 +28,39 @@
from erpnext.accounts.party import get_party_account_currency
+class InvoiceCancelled(frappe.ValidationError):
+ pass
+
+
+class InvoiceNotCancelled(frappe.ValidationError):
+ pass
+
+
class Subscription(Document):
def before_insert(self):
# update start just before the subscription doc is created
self.update_subscription_period(self.start_date)
- def update_subscription_period(self, date=None, return_date=False):
+ def update_subscription_period(self, date: Optional[Union[datetime.date, str]] = None):
"""
Subscription period is the period to be billed. This method updates the
beginning of the billing period and end of the billing period.
-
The beginning of the billing period is represented in the doctype as
`current_invoice_start` and the end of the billing period is represented
as `current_invoice_end`.
-
- If return_date is True, it wont update the start and end dates.
- This is implemented to get the dates to check if is_current_invoice_generated
"""
+ self.current_invoice_start = self.get_current_invoice_start(date)
+ self.current_invoice_end = self.get_current_invoice_end(self.current_invoice_start)
+
+ def _get_subscription_period(self, date: Optional[Union[datetime.date, str]] = None):
_current_invoice_start = self.get_current_invoice_start(date)
_current_invoice_end = self.get_current_invoice_end(_current_invoice_start)
- if return_date:
- return _current_invoice_start, _current_invoice_end
+ return _current_invoice_start, _current_invoice_end
- self.current_invoice_start = _current_invoice_start
- self.current_invoice_end = _current_invoice_end
-
- def get_current_invoice_start(self, date=None):
+ def get_current_invoice_start(
+ self, date: Optional[Union[datetime.date, str]] = None
+ ) -> Union[datetime.date, str]:
"""
This returns the date of the beginning of the current billing period.
If the `date` parameter is not given , it will be automatically set as today's
@@ -75,13 +83,13 @@
return _current_invoice_start
- def get_current_invoice_end(self, date=None):
+ def get_current_invoice_end(
+ self, date: Optional[Union[datetime.date, str]] = None
+ ) -> Union[datetime.date, str]:
"""
This returns the date of the end of the current billing period.
-
If the subscription is in trial period, it will be set as the end of the
trial period.
-
If is not in a trial period, it will be `x` days from the beginning of the
current billing period where `x` is the billing interval from the
`Subscription Plan` in the `Subscription`.
@@ -105,24 +113,13 @@
_current_invoice_end = get_last_day(date)
if self.follow_calendar_months:
+ # Sets the end date
+ # eg if date is 17-Feb-2022, the invoice will be generated per month ie
+ # the invoice will be created from 17 Feb to 28 Feb
billing_info = self.get_billing_cycle_and_interval()
billing_interval_count = billing_info[0]["billing_interval_count"]
- calendar_months = get_calendar_months(billing_interval_count)
- calendar_month = 0
- current_invoice_end_month = getdate(_current_invoice_end).month
- current_invoice_end_year = getdate(_current_invoice_end).year
-
- for month in calendar_months:
- if month <= current_invoice_end_month:
- calendar_month = month
-
- if cint(calendar_month - billing_interval_count) <= 0 and getdate(date).month != 1:
- calendar_month = 12
- current_invoice_end_year -= 1
-
- _current_invoice_end = get_last_day(
- cstr(current_invoice_end_year) + "-" + cstr(calendar_month) + "-01"
- )
+ _end = add_months(getdate(date), billing_interval_count - 1)
+ _current_invoice_end = get_last_day(_end)
if self.end_date and getdate(_current_invoice_end) > getdate(self.end_date):
_current_invoice_end = self.end_date
@@ -130,7 +127,7 @@
return _current_invoice_end
@staticmethod
- def validate_plans_billing_cycle(billing_cycle_data):
+ def validate_plans_billing_cycle(billing_cycle_data: List[Dict[str, str]]) -> None:
"""
Makes sure that all `Subscription Plan` in the `Subscription` have the
same billing interval
@@ -138,10 +135,9 @@
if billing_cycle_data and len(billing_cycle_data) != 1:
frappe.throw(_("You can only have Plans with the same billing cycle in a Subscription"))
- def get_billing_cycle_and_interval(self):
+ def get_billing_cycle_and_interval(self) -> List[Dict[str, str]]:
"""
Returns a dict representing the billing interval and cycle for this `Subscription`.
-
You shouldn't need to call this directly. Use `get_billing_cycle` instead.
"""
plan_names = [plan.plan for plan in self.plans]
@@ -156,72 +152,65 @@
return billing_info
- def get_billing_cycle_data(self):
+ def get_billing_cycle_data(self) -> Dict[str, int]:
"""
Returns dict contain the billing cycle data.
-
You shouldn't need to call this directly. Use `get_billing_cycle` instead.
"""
billing_info = self.get_billing_cycle_and_interval()
+ if not billing_info:
+ return None
- self.validate_plans_billing_cycle(billing_info)
+ data = dict()
+ interval = billing_info[0]["billing_interval"]
+ interval_count = billing_info[0]["billing_interval_count"]
- if billing_info:
- data = dict()
- interval = billing_info[0]["billing_interval"]
- interval_count = billing_info[0]["billing_interval_count"]
- if interval not in ["Day", "Week"]:
- data["days"] = -1
- if interval == "Day":
- data["days"] = interval_count - 1
- elif interval == "Month":
- data["months"] = interval_count
- elif interval == "Year":
- data["years"] = interval_count
- # todo: test week
- elif interval == "Week":
- data["days"] = interval_count * 7 - 1
+ if interval not in ["Day", "Week"]:
+ data["days"] = -1
- return data
+ if interval == "Day":
+ data["days"] = interval_count - 1
+ elif interval == "Week":
+ data["days"] = interval_count * 7 - 1
+ elif interval == "Month":
+ data["months"] = interval_count
+ elif interval == "Year":
+ data["years"] = interval_count
- def set_status_grace_period(self):
- """
- Sets the `Subscription` `status` based on the preference set in `Subscription Settings`.
+ return data
- Used when the `Subscription` needs to decide what to do after the current generated
- invoice is past it's due date and grace period.
- """
- subscription_settings = frappe.get_single("Subscription Settings")
- if self.status == "Past Due Date" and self.is_past_grace_period():
- self.status = "Cancelled" if cint(subscription_settings.cancel_after_grace) else "Unpaid"
-
- def set_subscription_status(self):
+ def set_subscription_status(self) -> None:
"""
Sets the status of the `Subscription`
"""
if self.is_trialling():
self.status = "Trialling"
- elif self.status == "Active" and self.end_date and getdate() > getdate(self.end_date):
+ elif (
+ self.status == "Active"
+ and self.end_date
+ and getdate(frappe.flags.current_date) > getdate(self.end_date)
+ ):
self.status = "Completed"
elif self.is_past_grace_period():
- subscription_settings = frappe.get_single("Subscription Settings")
- self.status = "Cancelled" if cint(subscription_settings.cancel_after_grace) else "Unpaid"
+ self.status = self.get_status_for_past_grace_period()
+ self.cancelation_date = (
+ getdate(frappe.flags.current_date) if self.status == "Cancelled" else None
+ )
elif self.current_invoice_is_past_due() and not self.is_past_grace_period():
self.status = "Past Due Date"
- elif not self.has_outstanding_invoice():
+ elif not self.has_outstanding_invoice() or self.is_new_subscription():
self.status = "Active"
- elif self.is_new_subscription():
- self.status = "Active"
+
self.save()
- def is_trialling(self):
+ def is_trialling(self) -> bool:
"""
Returns `True` if the `Subscription` is in trial period.
"""
return not self.period_has_passed(self.trial_period_end) and self.is_new_subscription()
@staticmethod
- def period_has_passed(end_date):
+ def period_has_passed(end_date: Union[str, datetime.date]) -> bool:
"""
Returns true if the given `end_date` has passed
"""
@@ -229,61 +218,59 @@
if not end_date:
return True
- end_date = getdate(end_date)
- return getdate() > getdate(end_date)
+ return getdate(frappe.flags.current_date) > getdate(end_date)
- def is_past_grace_period(self):
+ def get_status_for_past_grace_period(self) -> str:
+ cancel_after_grace = cint(frappe.get_value("Subscription Settings", None, "cancel_after_grace"))
+ status = "Unpaid"
+
+ if cancel_after_grace:
+ status = "Cancelled"
+
+ return status
+
+ def is_past_grace_period(self) -> bool:
"""
Returns `True` if the grace period for the `Subscription` has passed
"""
- current_invoice = self.get_current_invoice()
- if self.current_invoice_is_past_due(current_invoice):
- subscription_settings = frappe.get_single("Subscription Settings")
- grace_period = cint(subscription_settings.grace_period)
+ if not self.current_invoice_is_past_due():
+ return
- return getdate() > add_days(current_invoice.due_date, grace_period)
+ grace_period = cint(frappe.get_value("Subscription Settings", None, "grace_period"))
+ return getdate(frappe.flags.current_date) >= getdate(
+ add_days(self.current_invoice.due_date, grace_period)
+ )
- def current_invoice_is_past_due(self, current_invoice=None):
+ def current_invoice_is_past_due(self) -> bool:
"""
Returns `True` if the current generated invoice is overdue
"""
- if not current_invoice:
- current_invoice = self.get_current_invoice()
-
- if not current_invoice or self.is_paid(current_invoice):
+ if not self.current_invoice or self.is_paid(self.current_invoice):
return False
- else:
- return getdate() > getdate(current_invoice.due_date)
- def get_current_invoice(self):
- """
- Returns the most recent generated invoice.
- """
- doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
+ return getdate(frappe.flags.current_date) >= getdate(self.current_invoice.due_date)
- if len(self.invoices):
- current = self.invoices[-1]
- if frappe.db.exists(doctype, current.get("invoice")):
- doc = frappe.get_doc(doctype, current.get("invoice"))
- return doc
- else:
- frappe.throw(_("Invoice {0} no longer exists").format(current.get("invoice")))
+ @property
+ def invoice_document_type(self) -> str:
+ return "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
- def is_new_subscription(self):
+ def is_new_subscription(self) -> bool:
"""
Returns `True` if `Subscription` has never generated an invoice
"""
- return len(self.invoices) == 0
+ return self.is_new() or not frappe.db.exists(
+ {"doctype": self.invoice_document_type, "subscription": self.name}
+ )
- def validate(self):
+ def validate(self) -> None:
self.validate_trial_period()
self.validate_plans_billing_cycle(self.get_billing_cycle_and_interval())
self.validate_end_date()
self.validate_to_follow_calendar_months()
if not self.cost_center:
- self.cost_center = erpnext.get_default_cost_center(self.get("company"))
+ self.cost_center = get_default_cost_center(self.get("company"))
- def validate_trial_period(self):
+ def validate_trial_period(self) -> None:
"""
Runs sanity checks on trial period dates for the `Subscription`
"""
@@ -297,7 +284,7 @@
if self.trial_period_start and getdate(self.trial_period_start) > getdate(self.start_date):
frappe.throw(_("Trial Period Start date cannot be after Subscription Start Date"))
- def validate_end_date(self):
+ def validate_end_date(self) -> None:
billing_cycle_info = self.get_billing_cycle_data()
end_date = add_to_date(self.start_date, **billing_cycle_info)
@@ -306,53 +293,53 @@
_("Subscription End Date must be after {0} as per the subscription plan").format(end_date)
)
- def validate_to_follow_calendar_months(self):
- if self.follow_calendar_months:
- billing_info = self.get_billing_cycle_and_interval()
+ def validate_to_follow_calendar_months(self) -> None:
+ if not self.follow_calendar_months:
+ return
- if not self.end_date:
- frappe.throw(_("Subscription End Date is mandatory to follow calendar months"))
+ billing_info = self.get_billing_cycle_and_interval()
- if billing_info[0]["billing_interval"] != "Month":
- frappe.throw(
- _("Billing Interval in Subscription Plan must be Month to follow calendar months")
- )
+ if not self.end_date:
+ frappe.throw(_("Subscription End Date is mandatory to follow calendar months"))
- def after_insert(self):
+ if billing_info[0]["billing_interval"] != "Month":
+ frappe.throw(_("Billing Interval in Subscription Plan must be Month to follow calendar months"))
+
+ def after_insert(self) -> None:
# todo: deal with users who collect prepayments. Maybe a new Subscription Invoice doctype?
self.set_subscription_status()
- def generate_invoice(self, prorate=0):
+ def generate_invoice(
+ self,
+ from_date: Optional[Union[str, datetime.date]] = None,
+ to_date: Optional[Union[str, datetime.date]] = None,
+ ) -> Document:
"""
Creates a `Invoice` for the `Subscription`, updates `self.invoices` and
saves the `Subscription`.
+ Backwards compatibility
"""
+ return self.create_invoice(from_date=from_date, to_date=to_date)
- doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
-
- invoice = self.create_invoice(prorate)
- self.append("invoices", {"document_type": doctype, "invoice": invoice.name})
-
- self.save()
-
- return invoice
-
- def create_invoice(self, prorate):
+ def create_invoice(
+ self,
+ from_date: Optional[Union[str, datetime.date]] = None,
+ to_date: Optional[Union[str, datetime.date]] = None,
+ ) -> Document:
"""
Creates a `Invoice`, submits it and returns it
"""
- doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
-
- invoice = frappe.new_doc(doctype)
-
# For backward compatibility
# Earlier subscription didn't had any company field
company = self.get("company") or get_default_company()
if not company:
+ # fmt: off
frappe.throw(
- _("Company is mandatory was generating invoice. Please set default company in Global Defaults")
+ _("Company is mandatory was generating invoice. Please set default company in Global Defaults.")
)
+ # fmt: on
+ invoice = frappe.new_doc(self.invoice_document_type)
invoice.company = company
invoice.set_posting_time = 1
invoice.posting_date = (
@@ -363,17 +350,17 @@
invoice.cost_center = self.cost_center
- if doctype == "Sales Invoice":
+ if self.invoice_document_type == "Sales Invoice":
invoice.customer = self.party
else:
invoice.supplier = self.party
if frappe.db.get_value("Supplier", self.party, "tax_withholding_category"):
invoice.apply_tds = 1
- ### Add party currency to invoice
+ # Add party currency to invoice
invoice.currency = get_party_account_currency(self.party_type, self.party, self.company)
- ## Add dimensions in invoice for subscription:
+ # Add dimensions in invoice for subscription:
accounting_dimensions = get_accounting_dimensions()
for dimension in accounting_dimensions:
@@ -382,7 +369,7 @@
# Subscription is better suited for service items. I won't update `update_stock`
# for that reason
- items_list = self.get_items_from_plans(self.plans, prorate)
+ items_list = self.get_items_from_plans(self.plans, is_prorate())
for item in items_list:
item["cost_center"] = self.cost_center
invoice.append("items", item)
@@ -390,9 +377,9 @@
# Taxes
tax_template = ""
- if doctype == "Sales Invoice" and self.sales_tax_template:
+ if self.invoice_document_type == "Sales Invoice" and self.sales_tax_template:
tax_template = self.sales_tax_template
- if doctype == "Purchase Invoice" and self.purchase_tax_template:
+ if self.invoice_document_type == "Purchase Invoice" and self.purchase_tax_template:
tax_template = self.purchase_tax_template
if tax_template:
@@ -424,8 +411,9 @@
invoice.apply_discount_on = discount_on if discount_on else "Grand Total"
# Subscription period
- invoice.from_date = self.current_invoice_start
- invoice.to_date = self.current_invoice_end
+ invoice.subscription = self.name
+ invoice.from_date = from_date or self.current_invoice_start
+ invoice.to_date = to_date or self.current_invoice_end
invoice.flags.ignore_mandatory = True
@@ -437,13 +425,20 @@
return invoice
- def get_items_from_plans(self, plans, prorate=0):
+ def get_items_from_plans(
+ self, plans: List[Dict[str, str]], prorate: Optional[bool] = None
+ ) -> List[Dict]:
"""
Returns the `Item`s linked to `Subscription Plan`
"""
+ if prorate is None:
+ prorate = False
+
if prorate:
prorate_factor = get_prorata_factor(
- self.current_invoice_end, self.current_invoice_start, self.generate_invoice_at_period_start
+ self.current_invoice_end,
+ self.current_invoice_start,
+ cint(self.generate_invoice_at_period_start),
)
items = []
@@ -465,7 +460,11 @@
"item_code": item_code,
"qty": plan.qty,
"rate": get_plan_rate(
- plan.plan, plan.qty, party, self.current_invoice_start, self.current_invoice_end
+ plan.plan,
+ plan.qty,
+ party,
+ self.current_invoice_start,
+ self.current_invoice_end,
),
"cost_center": plan_doc.cost_center,
}
@@ -503,254 +502,184 @@
return items
- def process(self):
+ @frappe.whitelist()
+ def process(self) -> bool:
"""
To be called by task periodically. It checks the subscription and takes appropriate action
as need be. It calls either of these methods depending the `Subscription` status:
1. `process_for_active`
2. `process_for_past_due`
"""
- if self.status == "Active":
- self.process_for_active()
- elif self.status in ["Past Due Date", "Unpaid"]:
- self.process_for_past_due_date()
+ if (
+ not self.is_current_invoice_generated(self.current_invoice_start, self.current_invoice_end)
+ and self.can_generate_new_invoice()
+ ):
+ self.generate_invoice()
+ self.update_subscription_period(add_days(self.current_invoice_end, 1))
+
+ if self.cancel_at_period_end and (
+ getdate(frappe.flags.current_date) >= getdate(self.current_invoice_end)
+ or getdate(frappe.flags.current_date) >= getdate(self.end_date)
+ ):
+ self.cancel_subscription()
self.set_subscription_status()
self.save()
- def is_postpaid_to_invoice(self):
- return getdate() > getdate(self.current_invoice_end) or (
- getdate() >= getdate(self.current_invoice_end)
- and getdate(self.current_invoice_end) == getdate(self.current_invoice_start)
- )
+ def can_generate_new_invoice(self) -> bool:
+ if self.cancelation_date:
+ return False
+ elif self.generate_invoice_at_period_start and (
+ getdate(frappe.flags.current_date) == getdate(self.current_invoice_start)
+ or self.is_new_subscription()
+ ):
+ return True
+ elif getdate(frappe.flags.current_date) == getdate(self.current_invoice_end):
+ if self.has_outstanding_invoice() and not self.generate_new_invoices_past_due_date:
+ return False
- def is_prepaid_to_invoice(self):
- if not self.generate_invoice_at_period_start:
+ return True
+ else:
return False
- if self.is_new_subscription() and getdate() >= getdate(self.current_invoice_start):
- return True
-
- # Check invoice dates and make sure it doesn't have outstanding invoices
- return getdate() >= getdate(self.current_invoice_start)
-
- def is_current_invoice_generated(self, _current_start_date=None, _current_end_date=None):
- invoice = self.get_current_invoice()
-
+ def is_current_invoice_generated(
+ self,
+ _current_start_date: Union[datetime.date, str] = None,
+ _current_end_date: Union[datetime.date, str] = None,
+ ) -> bool:
if not (_current_start_date and _current_end_date):
- _current_start_date, _current_end_date = self.update_subscription_period(
- date=add_days(self.current_invoice_end, 1), return_date=True
+ _current_start_date, _current_end_date = self._get_subscription_period(
+ date=add_days(self.current_invoice_end, 1)
)
- if invoice and getdate(_current_start_date) <= getdate(invoice.posting_date) <= getdate(
- _current_end_date
- ):
+ if self.current_invoice and getdate(_current_start_date) <= getdate(
+ self.current_invoice.posting_date
+ ) <= getdate(_current_end_date):
return True
return False
- def process_for_active(self):
+ @property
+ def current_invoice(self) -> Union[Document, None]:
"""
- Called by `process` if the status of the `Subscription` is 'Active'.
-
- The possible outcomes of this method are:
- 1. Generate a new invoice
- 2. Change the `Subscription` status to 'Past Due Date'
- 3. Change the `Subscription` status to 'Cancelled'
+ Adds property for accessing the current_invoice
"""
+ return self.get_current_invoice()
- if not self.is_current_invoice_generated(
- self.current_invoice_start, self.current_invoice_end
- ) and (self.is_postpaid_to_invoice() or self.is_prepaid_to_invoice()):
+ def get_current_invoice(self) -> Union[Document, None]:
+ """
+ Returns the most recent generated invoice.
+ """
+ invoice = frappe.get_all(
+ self.invoice_document_type,
+ {
+ "subscription": self.name,
+ },
+ limit=1,
+ order_by="to_date desc",
+ pluck="name",
+ )
- prorate = frappe.db.get_single_value("Subscription Settings", "prorate")
- self.generate_invoice(prorate)
+ if invoice:
+ return frappe.get_doc(self.invoice_document_type, invoice[0])
- if getdate() > getdate(self.current_invoice_end) and self.is_prepaid_to_invoice():
- self.update_subscription_period(add_days(self.current_invoice_end, 1))
-
- if self.cancel_at_period_end and getdate() > getdate(self.current_invoice_end):
- self.cancel_subscription_at_period_end()
-
- def cancel_subscription_at_period_end(self):
+ def cancel_subscription_at_period_end(self) -> None:
"""
Called when `Subscription.cancel_at_period_end` is truthy
"""
- if self.end_date and getdate() < getdate(self.end_date):
- return
-
self.status = "Cancelled"
- if not self.cancelation_date:
- self.cancelation_date = nowdate()
+ self.cancelation_date = nowdate()
- def process_for_past_due_date(self):
- """
- Called by `process` if the status of the `Subscription` is 'Past Due Date'.
-
- The possible outcomes of this method are:
- 1. Change the `Subscription` status to 'Active'
- 2. Change the `Subscription` status to 'Cancelled'
- 3. Change the `Subscription` status to 'Unpaid'
- """
- current_invoice = self.get_current_invoice()
- if not current_invoice:
- frappe.throw(_("Current invoice {0} is missing").format(current_invoice.invoice))
- else:
- if not self.has_outstanding_invoice():
- self.status = "Active"
- else:
- self.set_status_grace_period()
-
- if getdate() > getdate(self.current_invoice_end):
- self.update_subscription_period(add_days(self.current_invoice_end, 1))
-
- # Generate invoices periodically even if current invoice are unpaid
- if (
- self.generate_new_invoices_past_due_date
- and not self.is_current_invoice_generated(self.current_invoice_start, self.current_invoice_end)
- and (self.is_postpaid_to_invoice() or self.is_prepaid_to_invoice())
- ):
-
- prorate = frappe.db.get_single_value("Subscription Settings", "prorate")
- self.generate_invoice(prorate)
+ @property
+ def invoices(self) -> List[Dict]:
+ return frappe.get_all(
+ self.invoice_document_type,
+ filters={"subscription": self.name},
+ order_by="from_date asc",
+ )
@staticmethod
- def is_paid(invoice):
+ def is_paid(invoice: Document) -> bool:
"""
Return `True` if the given invoice is paid
"""
return invoice.status == "Paid"
- def has_outstanding_invoice(self):
+ def has_outstanding_invoice(self) -> int:
"""
Returns `True` if the most recent invoice for the `Subscription` is not paid
"""
- doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
- current_invoice = self.get_current_invoice()
- invoice_list = [d.invoice for d in self.invoices]
-
- outstanding_invoices = frappe.get_all(
- doctype, fields=["name"], filters={"status": ("!=", "Paid"), "name": ("in", invoice_list)}
+ return frappe.db.count(
+ self.invoice_document_type,
+ {
+ "subscription": self.name,
+ "status": ["!=", "Paid"],
+ },
)
- if outstanding_invoices:
- return True
- else:
- False
-
- def cancel_subscription(self):
+ @frappe.whitelist()
+ def cancel_subscription(self) -> None:
"""
This sets the subscription as cancelled. It will stop invoices from being generated
but it will not affect already created invoices.
"""
- if self.status != "Cancelled":
- to_generate_invoice = (
- True if self.status == "Active" and not self.generate_invoice_at_period_start else False
- )
- to_prorate = frappe.db.get_single_value("Subscription Settings", "prorate")
- self.status = "Cancelled"
- self.cancelation_date = nowdate()
- if to_generate_invoice:
- self.generate_invoice(prorate=to_prorate)
- self.save()
+ if self.status == "Cancelled":
+ frappe.throw(_("subscription is already cancelled."), InvoiceCancelled)
- def restart_subscription(self):
+ to_generate_invoice = (
+ True if self.status == "Active" and not self.generate_invoice_at_period_start else False
+ )
+ self.status = "Cancelled"
+ self.cancelation_date = nowdate()
+
+ if to_generate_invoice:
+ self.generate_invoice(self.current_invoice_start, self.cancelation_date)
+
+ self.save()
+
+ @frappe.whitelist()
+ def restart_subscription(self) -> None:
"""
This sets the subscription as active. The subscription will be made to be like a new
subscription and the `Subscription` will lose all the history of generated invoices
it has.
"""
- if self.status == "Cancelled":
- self.status = "Active"
- self.db_set("start_date", nowdate())
- self.update_subscription_period(nowdate())
- self.invoices = []
- self.save()
- else:
- frappe.throw(_("You cannot restart a Subscription that is not cancelled."))
+ if not self.status == "Cancelled":
+ frappe.throw(_("You cannot restart a Subscription that is not cancelled."), InvoiceNotCancelled)
- def get_precision(self):
- invoice = self.get_current_invoice()
- if invoice:
- return invoice.precision("grand_total")
+ self.status = "Active"
+ self.cancelation_date = None
+ self.update_subscription_period(frappe.flags.current_date or nowdate())
+ self.save()
-def get_calendar_months(billing_interval):
- calendar_months = []
- start = 0
- while start < 12:
- start += billing_interval
- calendar_months.append(start)
-
- return calendar_months
+def is_prorate() -> int:
+ return cint(frappe.db.get_single_value("Subscription Settings", "prorate"))
-def get_prorata_factor(period_end, period_start, is_prepaid):
+def get_prorata_factor(
+ period_end: Union[datetime.date, str],
+ period_start: Union[datetime.date, str],
+ is_prepaid: Optional[int] = None,
+) -> Union[int, float]:
if is_prepaid:
- prorate_factor = 1
- else:
- diff = flt(date_diff(nowdate(), period_start) + 1)
- plan_days = flt(date_diff(period_end, period_start) + 1)
- prorate_factor = diff / plan_days
+ return 1
- return prorate_factor
+ diff = flt(date_diff(nowdate(), period_start) + 1)
+ plan_days = flt(date_diff(period_end, period_start) + 1)
+ return diff / plan_days
-def process_all():
+def process_all() -> None:
"""
Task to updates the status of all `Subscription` apart from those that are cancelled
"""
- subscriptions = get_all_subscriptions()
- for subscription in subscriptions:
- process(subscription)
-
-
-def get_all_subscriptions():
- """
- Returns all `Subscription` documents
- """
- return frappe.db.get_all("Subscription", {"status": ("!=", "Cancelled")})
-
-
-def process(data):
- """
- Checks a `Subscription` and updates it status as necessary
- """
- if data:
+ for subscription in frappe.get_all("Subscription", {"status": ("!=", "Cancelled")}, pluck="name"):
try:
- subscription = frappe.get_doc("Subscription", data["name"])
+ subscription = frappe.get_doc("Subscription", subscription)
subscription.process()
frappe.db.commit()
except frappe.ValidationError:
frappe.db.rollback()
subscription.log_error("Subscription failed")
-
-
-@frappe.whitelist()
-def cancel_subscription(name):
- """
- Cancels a `Subscription`. This will stop the `Subscription` from further invoicing the
- `Subscriber` but all already outstanding invoices will not be affected.
- """
- subscription = frappe.get_doc("Subscription", name)
- subscription.cancel_subscription()
-
-
-@frappe.whitelist()
-def restart_subscription(name):
- """
- Restarts a cancelled `Subscription`. The `Subscription` will 'forget' the history of
- all invoices it has generated
- """
- subscription = frappe.get_doc("Subscription", name)
- subscription.restart_subscription()
-
-
-@frappe.whitelist()
-def get_subscription_updates(name):
- """
- Use this to get the latest state of the given `Subscription`
- """
- subscription = frappe.get_doc("Subscription", name)
- subscription.process()
diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py
index eb17daa..0bb171f 100644
--- a/erpnext/accounts/doctype/subscription/test_subscription.py
+++ b/erpnext/accounts/doctype/subscription/test_subscription.py
@@ -11,6 +11,7 @@
date_diff,
flt,
get_date_str,
+ getdate,
nowdate,
)
@@ -90,10 +91,18 @@
customer.insert()
+def reset_settings():
+ settings = frappe.get_single("Subscription Settings")
+ settings.grace_period = 0
+ settings.cancel_after_grace = 0
+ settings.save()
+
+
class TestSubscription(unittest.TestCase):
def setUp(self):
create_plan()
create_parties()
+ reset_settings()
def test_create_subscription_with_trial_with_correct_period(self):
subscription = frappe.new_doc("Subscription")
@@ -116,8 +125,6 @@
self.assertEqual(subscription.invoices, [])
self.assertEqual(subscription.status, "Trialling")
- subscription.delete()
-
def test_create_subscription_without_trial_with_correct_period(self):
subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer"
@@ -133,8 +140,6 @@
self.assertEqual(len(subscription.invoices), 0)
self.assertEqual(subscription.status, "Active")
- subscription.delete()
-
def test_create_subscription_trial_with_wrong_dates(self):
subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer"
@@ -144,7 +149,6 @@
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
self.assertRaises(frappe.ValidationError, subscription.save)
- subscription.delete()
def test_create_subscription_multi_with_different_billing_fails(self):
subscription = frappe.new_doc("Subscription")
@@ -156,7 +160,6 @@
subscription.append("plans", {"plan": "_Test Plan Name 3", "qty": 1})
self.assertRaises(frappe.ValidationError, subscription.save)
- subscription.delete()
def test_invoice_is_generated_at_end_of_billing_period(self):
subscription = frappe.new_doc("Subscription")
@@ -169,13 +172,13 @@
self.assertEqual(subscription.status, "Active")
self.assertEqual(subscription.current_invoice_start, "2018-01-01")
self.assertEqual(subscription.current_invoice_end, "2018-01-31")
+ frappe.flags.current_date = "2018-01-31"
subscription.process()
self.assertEqual(len(subscription.invoices), 1)
- self.assertEqual(subscription.current_invoice_start, "2018-01-01")
- subscription.process()
+ self.assertEqual(subscription.current_invoice_start, "2018-02-01")
+ self.assertEqual(subscription.current_invoice_end, "2018-02-28")
self.assertEqual(subscription.status, "Unpaid")
- subscription.delete()
def test_status_goes_back_to_active_after_invoice_is_paid(self):
subscription = frappe.new_doc("Subscription")
@@ -183,7 +186,9 @@
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.start_date = "2018-01-01"
+ subscription.generate_invoice_at_period_start = True
subscription.insert()
+ frappe.flags.current_date = "2018-01-01"
subscription.process() # generate first invoice
self.assertEqual(len(subscription.invoices), 1)
@@ -203,11 +208,8 @@
self.assertEqual(subscription.current_invoice_start, add_months(subscription.start_date, 1))
self.assertEqual(len(subscription.invoices), 1)
- subscription.delete()
-
def test_subscription_cancel_after_grace_period(self):
settings = frappe.get_single("Subscription Settings")
- default_grace_period_action = settings.cancel_after_grace
settings.cancel_after_grace = 1
settings.save()
@@ -215,20 +217,18 @@
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
+ # subscription.generate_invoice_at_period_start = True
subscription.start_date = "2018-01-01"
subscription.insert()
self.assertEqual(subscription.status, "Active")
+ frappe.flags.current_date = "2018-01-31"
subscription.process() # generate first invoice
# This should change status to Cancelled since grace period is 0
# And is backdated subscription so subscription will be cancelled after processing
self.assertEqual(subscription.status, "Cancelled")
- settings.cancel_after_grace = default_grace_period_action
- settings.save()
- subscription.delete()
-
def test_subscription_unpaid_after_grace_period(self):
settings = frappe.get_single("Subscription Settings")
default_grace_period_action = settings.cancel_after_grace
@@ -248,21 +248,26 @@
settings.cancel_after_grace = default_grace_period_action
settings.save()
- subscription.delete()
def test_subscription_invoice_days_until_due(self):
+ _date = add_months(nowdate(), -1)
subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.days_until_due = 10
- subscription.start_date = add_months(nowdate(), -1)
+ subscription.start_date = _date
+ subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.insert()
+
+ frappe.flags.current_date = subscription.current_invoice_end
+
subscription.process() # generate first invoice
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, "Active")
- subscription.delete()
+ frappe.flags.current_date = add_days(subscription.current_invoice_end, 3)
+ self.assertEqual(len(subscription.invoices), 1)
+ self.assertEqual(subscription.status, "Active")
def test_subscription_is_past_due_doesnt_change_within_grace_period(self):
settings = frappe.get_single("Subscription Settings")
@@ -276,6 +281,8 @@
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.start_date = add_days(nowdate(), -1000)
subscription.insert()
+
+ frappe.flags.current_date = subscription.current_invoice_end
subscription.process() # generate first invoice
self.assertEqual(subscription.status, "Past Due Date")
@@ -292,7 +299,6 @@
settings.grace_period = grace_period
settings.save()
- subscription.delete()
def test_subscription_remains_active_during_invoice_period(self):
subscription = frappe.new_doc("Subscription")
@@ -319,8 +325,6 @@
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(len(subscription.invoices), 0)
- subscription.delete()
-
def test_subscription_cancelation(self):
subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer"
@@ -331,8 +335,6 @@
self.assertEqual(subscription.status, "Cancelled")
- subscription.delete()
-
def test_subscription_cancellation_invoices(self):
settings = frappe.get_single("Subscription Settings")
to_prorate = settings.prorate
@@ -372,7 +374,6 @@
self.assertEqual(flt(invoice.grand_total, 2), flt(prorate_factor * 900, 2))
self.assertEqual(subscription.status, "Cancelled")
- subscription.delete()
settings.prorate = to_prorate
settings.save()
@@ -395,8 +396,6 @@
settings.prorate = to_prorate
settings.save()
- subscription.delete()
-
def test_subscription_cancellation_invoices_with_prorata_true(self):
settings = frappe.get_single("Subscription Settings")
to_prorate = settings.prorate
@@ -422,8 +421,6 @@
settings.prorate = to_prorate
settings.save()
- subscription.delete()
-
def test_subcription_cancellation_and_process(self):
settings = frappe.get_single("Subscription Settings")
default_grace_period_action = settings.cancel_after_grace
@@ -437,23 +434,22 @@
subscription.start_date = "2018-01-01"
subscription.insert()
subscription.process() # generate first invoice
- invoices = len(subscription.invoices)
+ # Generate an invoice for the cancelled period
subscription.cancel_subscription()
self.assertEqual(subscription.status, "Cancelled")
- self.assertEqual(len(subscription.invoices), invoices)
+ self.assertEqual(len(subscription.invoices), 1)
subscription.process()
self.assertEqual(subscription.status, "Cancelled")
- self.assertEqual(len(subscription.invoices), invoices)
+ self.assertEqual(len(subscription.invoices), 1)
subscription.process()
self.assertEqual(subscription.status, "Cancelled")
- self.assertEqual(len(subscription.invoices), invoices)
+ self.assertEqual(len(subscription.invoices), 1)
settings.cancel_after_grace = default_grace_period_action
settings.save()
- subscription.delete()
def test_subscription_restart_and_process(self):
settings = frappe.get_single("Subscription Settings")
@@ -468,6 +464,7 @@
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.start_date = "2018-01-01"
subscription.insert()
+ frappe.flags.current_date = "2018-01-31"
subscription.process() # generate first invoice
# Status is unpaid as Days until Due is zero and grace period is Zero
@@ -478,19 +475,18 @@
subscription.restart_subscription()
self.assertEqual(subscription.status, "Active")
- self.assertEqual(len(subscription.invoices), 0)
+ self.assertEqual(len(subscription.invoices), 1)
subscription.process()
- self.assertEqual(subscription.status, "Active")
- self.assertEqual(len(subscription.invoices), 0)
+ self.assertEqual(subscription.status, "Unpaid")
+ self.assertEqual(len(subscription.invoices), 1)
subscription.process()
- self.assertEqual(subscription.status, "Active")
- self.assertEqual(len(subscription.invoices), 0)
+ self.assertEqual(subscription.status, "Unpaid")
+ self.assertEqual(len(subscription.invoices), 1)
settings.cancel_after_grace = default_grace_period_action
settings.save()
- subscription.delete()
def test_subscription_unpaid_back_to_active(self):
settings = frappe.get_single("Subscription Settings")
@@ -503,8 +499,11 @@
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.start_date = "2018-01-01"
+ subscription.generate_invoice_at_period_start = True
subscription.insert()
+ frappe.flags.current_date = subscription.current_invoice_start
+
subscription.process() # generate first invoice
# This should change status to Unpaid since grace period is 0
self.assertEqual(subscription.status, "Unpaid")
@@ -517,12 +516,12 @@
self.assertEqual(subscription.status, "Active")
# A new invoice is generated
+ frappe.flags.current_date = subscription.current_invoice_start
subscription.process()
self.assertEqual(subscription.status, "Unpaid")
settings.cancel_after_grace = default_grace_period_action
settings.save()
- subscription.delete()
def test_restart_active_subscription(self):
subscription = frappe.new_doc("Subscription")
@@ -533,8 +532,6 @@
self.assertRaises(frappe.ValidationError, subscription.restart_subscription)
- subscription.delete()
-
def test_subscription_invoice_discount_percentage(self):
subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer"
@@ -549,8 +546,6 @@
self.assertEqual(invoice.additional_discount_percentage, 10)
self.assertEqual(invoice.apply_discount_on, "Grand Total")
- subscription.delete()
-
def test_subscription_invoice_discount_amount(self):
subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer"
@@ -565,8 +560,6 @@
self.assertEqual(invoice.discount_amount, 11)
self.assertEqual(invoice.apply_discount_on, "Grand Total")
- subscription.delete()
-
def test_prepaid_subscriptions(self):
# Create a non pre-billed subscription, processing should not create
# invoices.
@@ -614,8 +607,6 @@
settings.prorate = to_prorate
settings.save()
- subscription.delete()
-
def test_subscription_with_follow_calendar_months(self):
subscription = frappe.new_doc("Subscription")
subscription.party_type = "Supplier"
@@ -623,14 +614,14 @@
subscription.generate_invoice_at_period_start = 1
subscription.follow_calendar_months = 1
- # select subscription start date as '2018-01-15'
+ # select subscription start date as "2018-01-15"
subscription.start_date = "2018-01-15"
subscription.end_date = "2018-07-15"
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
subscription.save()
- # even though subscription starts at '2018-01-15' and Billing interval is Month and count 3
- # First invoice will end at '2018-03-31' instead of '2018-04-14'
+ # even though subscription starts at "2018-01-15" and Billing interval is Month and count 3
+ # First invoice will end at "2018-03-31" instead of "2018-04-14"
self.assertEqual(get_date_str(subscription.current_invoice_end), "2018-03-31")
def test_subscription_generate_invoice_past_due(self):
@@ -639,11 +630,12 @@
subscription.party = "_Test Supplier"
subscription.generate_invoice_at_period_start = 1
subscription.generate_new_invoices_past_due_date = 1
- # select subscription start date as '2018-01-15'
+ # select subscription start date as "2018-01-15"
subscription.start_date = "2018-01-01"
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
subscription.save()
+ frappe.flags.current_date = "2018-01-01"
# Process subscription and create first invoice
# Subscription status will be unpaid since due date has already passed
subscription.process()
@@ -652,8 +644,8 @@
# Now the Subscription is unpaid
# Even then new invoice should be created as we have enabled `generate_new_invoices_past_due_date` in
- # subscription
-
+ # subscription and the interval between the subscriptions is 3 months
+ frappe.flags.current_date = "2018-04-01"
subscription.process()
self.assertEqual(len(subscription.invoices), 2)
@@ -662,7 +654,7 @@
subscription.party_type = "Supplier"
subscription.party = "_Test Supplier"
subscription.generate_invoice_at_period_start = 1
- # select subscription start date as '2018-01-15'
+ # select subscription start date as "2018-01-15"
subscription.start_date = "2018-01-01"
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
subscription.save()
@@ -682,7 +674,7 @@
subscription.party = "_Test Subscription Customer"
subscription.generate_invoice_at_period_start = 1
subscription.company = "_Test Company"
- # select subscription start date as '2018-01-15'
+ # select subscription start date as "2018-01-15"
subscription.start_date = "2018-01-01"
subscription.append("plans", {"plan": "_Test Plan Multicurrency", "qty": 1})
subscription.save()
@@ -692,5 +684,47 @@
self.assertEqual(subscription.status, "Unpaid")
# Check the currency of the created invoice
- currency = frappe.db.get_value("Sales Invoice", subscription.invoices[0].invoice, "currency")
+ currency = frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "currency")
self.assertEqual(currency, "USD")
+
+ def test_subscription_recovery(self):
+ """Test if Subscription recovers when start/end date run out of sync with created invoices."""
+ subscription = frappe.new_doc("Subscription")
+ subscription.party_type = "Customer"
+ subscription.party = "_Test Subscription Customer"
+ subscription.company = "_Test Company"
+ subscription.start_date = "2021-12-01"
+ subscription.generate_new_invoices_past_due_date = 1
+ subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
+ subscription.submit_invoice = 0
+ subscription.save()
+
+ # create invoices for the first two moths
+ frappe.flags.current_date = "2021-12-31"
+ subscription.process()
+
+ frappe.flags.current_date = "2022-01-31"
+ subscription.process()
+
+ self.assertEqual(len(subscription.invoices), 2)
+ self.assertEqual(
+ getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")),
+ getdate("2021-12-01"),
+ )
+ self.assertEqual(
+ getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")),
+ getdate("2022-01-01"),
+ )
+
+ # recreate most recent invoice
+ subscription.process()
+
+ self.assertEqual(len(subscription.invoices), 2)
+ self.assertEqual(
+ getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")),
+ getdate("2021-12-01"),
+ )
+ self.assertEqual(
+ getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")),
+ getdate("2022-01-01"),
+ )
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 641d755..d53bace 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -262,6 +262,7 @@
erpnext.patches.v15_0.saudi_depreciation_warning
erpnext.patches.v15_0.delete_saudi_doctypes
erpnext.patches.v14_0.show_loan_management_deprecation_warning
+erpnext.patches.v14_0.update_subscription_details
execute:frappe.rename_doc("Report", "TDS Payable Monthly", "Tax Withholding Details", force=True)
[post_model_sync]
diff --git a/erpnext/patches/v14_0/update_subscription_details.py b/erpnext/patches/v14_0/update_subscription_details.py
new file mode 100644
index 0000000..729ac18
--- /dev/null
+++ b/erpnext/patches/v14_0/update_subscription_details.py
@@ -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")