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