feat: subscription refactor (#30963)

* feat: subscription refactor

* fix: linter changes

* chore: linter changes

* chore: linter changes

* chore: Update tests

* chore: Remove commits

---------

Co-authored-by: Deepesh Garg <deepeshgarg6@gmail.com>
This commit is contained in:
Himanshu 2023-08-07 08:33:47 +05:30 committed by GitHub
parent b717e2b5bf
commit 38805603db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 438 additions and 466 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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();
}
});
}

View File

@ -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
}

View File

@ -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()

View File

@ -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"),
)

View File

@ -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]

View File

@ -0,0 +1,17 @@
import frappe
def execute():
subscription_invoices = frappe.get_all(
"Subscription Invoice", fields=["document_type", "invoice", "parent"]
)
for subscription_invoice in subscription_invoices:
frappe.db.set_value(
subscription_invoice.document_type,
subscription_invoice.invoice,
"Subscription",
subscription_invoice.parent,
)
frappe.delete_doc_if_exists("DocType", "Subscription Invoice")