mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-01 07:37:04 +00:00
feat(subscription): add refunded status, billing heatmap and billing UX (#55617)
* fix(subscription): bill on creation and keep status in sync with invoices * feat(subscription): add refunded status, billing heatmap and billing UX
This commit is contained in:
@@ -653,6 +653,9 @@ class PurchaseInvoice(BuyingController):
|
|||||||
|
|
||||||
self.process_common_party_accounting()
|
self.process_common_party_accounting()
|
||||||
|
|
||||||
|
if self.is_return:
|
||||||
|
self.refresh_subscription_status()
|
||||||
|
|
||||||
def on_update_after_submit(self):
|
def on_update_after_submit(self):
|
||||||
fields_to_check = [
|
fields_to_check = [
|
||||||
"cash_bank_account",
|
"cash_bank_account",
|
||||||
@@ -772,6 +775,8 @@ class PurchaseInvoice(BuyingController):
|
|||||||
"Tax Withholding Entry",
|
"Tax Withholding Entry",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.refresh_subscription_status()
|
||||||
|
|
||||||
def update_project(self):
|
def update_project(self):
|
||||||
projects = frappe._dict()
|
projects = frappe._dict()
|
||||||
for d in self.items:
|
for d in self.items:
|
||||||
|
|||||||
@@ -497,6 +497,9 @@ class SalesInvoice(SellingController):
|
|||||||
self.process_common_party_accounting()
|
self.process_common_party_accounting()
|
||||||
self.update_billed_qty_in_scio()
|
self.update_billed_qty_in_scio()
|
||||||
|
|
||||||
|
if self.is_return:
|
||||||
|
self.refresh_subscription_status()
|
||||||
|
|
||||||
def before_cancel(self):
|
def before_cancel(self):
|
||||||
POSService(self).check_if_created_using_pos_and_pos_closing_entry_generated()
|
POSService(self).check_if_created_using_pos_and_pos_closing_entry_generated()
|
||||||
POSService(self).check_if_consolidated_invoice()
|
POSService(self).check_if_consolidated_invoice()
|
||||||
@@ -584,6 +587,7 @@ class SalesInvoice(SellingController):
|
|||||||
POSService(self).cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode()
|
POSService(self).cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode()
|
||||||
|
|
||||||
self.update_billed_qty_in_scio()
|
self.update_billed_qty_in_scio()
|
||||||
|
self.refresh_subscription_status()
|
||||||
|
|
||||||
def update_status_updater_args(self):
|
def update_status_updater_args(self):
|
||||||
if not cint(self.update_stock):
|
if not cint(self.update_stock):
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ frappe.ui.form.on("Subscription", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
refresh: function (frm) {
|
refresh: function (frm) {
|
||||||
if (frm.is_new()) return;
|
if (frm.is_new()) {
|
||||||
|
// The field wrapper is reused across docs; clear any stale heatmap.
|
||||||
|
frm.get_field("billing_heatmap").$wrapper.empty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
frm.trigger("render_billing_heatmap");
|
||||||
|
|
||||||
if (frm.doc.status !== "Cancelled") {
|
if (frm.doc.status !== "Cancelled") {
|
||||||
frm.add_custom_button(
|
frm.add_custom_button(
|
||||||
@@ -95,4 +101,88 @@ frappe.ui.form.on("Subscription", {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
render_billing_heatmap: function (frm) {
|
||||||
|
frm.call("get_billing_heatmap").then((r) => {
|
||||||
|
if (!r.message || !r.message.length) return;
|
||||||
|
render_heatmap(frm.get_field("billing_heatmap").$wrapper, r.message, frm.doc);
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Status -> colour and label for the calendar heatmap. Keys are Title-case to
|
||||||
|
// match the value frappe-charts shows in its hover tooltip.
|
||||||
|
const HEATMAP_COLORS = {
|
||||||
|
Paid: "#39d353",
|
||||||
|
Unpaid: "#388bfd",
|
||||||
|
Overdue: "#f0883e",
|
||||||
|
Cancelled: "#f85149",
|
||||||
|
Refunded: "#a371f7",
|
||||||
|
Planned: "#87ceeb",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Days inside the window but outside the subscription's active span stay faded.
|
||||||
|
const EMPTY_COLOR = "#ebedf0";
|
||||||
|
|
||||||
|
function title_case(status) {
|
||||||
|
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_heatmap($wrapper, days, doc) {
|
||||||
|
const data_points = {};
|
||||||
|
days.forEach((day) => {
|
||||||
|
data_points[day.date] = title_case(day.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
$wrapper.empty();
|
||||||
|
const chart_el = $('<div class="subscription-billing-heatmap"></div>').appendTo($wrapper)[0];
|
||||||
|
|
||||||
|
new frappe.Chart(chart_el, {
|
||||||
|
type: "heatmap",
|
||||||
|
data: {
|
||||||
|
dataPoints: data_points,
|
||||||
|
start: new Date(days[0].date),
|
||||||
|
end: new Date(days[days.length - 1].date),
|
||||||
|
},
|
||||||
|
discreteDomains: 1,
|
||||||
|
showLegend: 0,
|
||||||
|
// frappe-charts only does an intensity scale; we recolour each square by
|
||||||
|
// its own status below, so the scale colours are placeholders.
|
||||||
|
colors: ["#ebedf0", "#ebedf0", "#ebedf0", "#ebedf0", "#ebedf0"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Paint every day square with its status colour (data-value holds the status).
|
||||||
|
// The chart re-renders once for its entry animation, so repaint on each redraw.
|
||||||
|
const within_subscription = (date) =>
|
||||||
|
(!doc.start_date || date >= doc.start_date) && (!doc.end_date || date <= doc.end_date);
|
||||||
|
|
||||||
|
const paint = () =>
|
||||||
|
chart_el.querySelectorAll("[data-date]").forEach((square) => {
|
||||||
|
const status = square.getAttribute("data-value");
|
||||||
|
if (status === "Planned" && !within_subscription(square.getAttribute("data-date"))) {
|
||||||
|
// Outside the subscription's span: render blank and drop the status so the
|
||||||
|
// hover tooltip shows only the date, not "Planned".
|
||||||
|
square.setAttribute("fill", EMPTY_COLOR);
|
||||||
|
square.setAttribute("data-value", "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
square.setAttribute("fill", HEATMAP_COLORS[status] || EMPTY_COLOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
paint();
|
||||||
|
new MutationObserver(paint).observe(chart_el, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
const legend = Object.keys(HEATMAP_COLORS)
|
||||||
|
.map(
|
||||||
|
(status) =>
|
||||||
|
`<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px;">
|
||||||
|
<span style="width:11px;height:11px;border-radius:2px;background:${HEATMAP_COLORS[status]};"></span>
|
||||||
|
${__(status)}
|
||||||
|
</span>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
$(`<div style="margin-top:8px;font-size:11px;color:var(--text-muted);">${legend}</div>`).appendTo(
|
||||||
|
$wrapper
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
"editable_grid": 1,
|
"editable_grid": 1,
|
||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
|
"billing_history_section",
|
||||||
|
"billing_heatmap",
|
||||||
|
"section_break_jznv",
|
||||||
"party_type",
|
"party_type",
|
||||||
"party",
|
"party",
|
||||||
"cb_1",
|
"cb_1",
|
||||||
@@ -21,12 +24,16 @@
|
|||||||
"generate_new_invoices_past_due_date",
|
"generate_new_invoices_past_due_date",
|
||||||
"submit_invoice",
|
"submit_invoice",
|
||||||
"column_break_11",
|
"column_break_11",
|
||||||
"current_invoice_start",
|
|
||||||
"current_invoice_end",
|
|
||||||
"days_until_due",
|
"days_until_due",
|
||||||
"generate_invoice_at",
|
"generate_invoice_at",
|
||||||
"number_of_days",
|
"number_of_days",
|
||||||
"cancel_at_period_end",
|
"cancel_at_period_end",
|
||||||
|
"billing_period_section",
|
||||||
|
"current_invoice_start",
|
||||||
|
"current_invoice_end",
|
||||||
|
"billing_period_cb",
|
||||||
|
"next_billing_period_start",
|
||||||
|
"next_billing_period_end",
|
||||||
"sb_4",
|
"sb_4",
|
||||||
"plans",
|
"plans",
|
||||||
"sb_1",
|
"sb_1",
|
||||||
@@ -51,7 +58,7 @@
|
|||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"label": "Status",
|
"label": "Status",
|
||||||
"no_copy": 1,
|
"no_copy": 1,
|
||||||
"options": "\nTrialing\nActive\nGrace Period\nCancelled\nUnpaid\nCompleted",
|
"options": "\nTrialing\nActive\nGrace Period\nCancelled\nUnpaid\nCompleted\nRefunded",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -83,17 +90,40 @@
|
|||||||
"fieldname": "column_break_11",
|
"fieldname": "column_break_11",
|
||||||
"fieldtype": "Column Break"
|
"fieldtype": "Column Break"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "billing_period_section",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Billing Period"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "current_invoice_start",
|
"fieldname": "current_invoice_start",
|
||||||
"fieldtype": "Date",
|
"fieldtype": "Date",
|
||||||
"label": "Current Invoice Start Date",
|
"label": "Current Invoice Start",
|
||||||
"no_copy": 1,
|
"no_copy": 1,
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "current_invoice_end",
|
"fieldname": "current_invoice_end",
|
||||||
"fieldtype": "Date",
|
"fieldtype": "Date",
|
||||||
"label": "Current Invoice End Date",
|
"label": "Current Invoice End",
|
||||||
|
"no_copy": 1,
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "billing_period_cb",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "next_billing_period_start",
|
||||||
|
"fieldtype": "Date",
|
||||||
|
"label": "Next Billing Period Start",
|
||||||
|
"no_copy": 1,
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "next_billing_period_end",
|
||||||
|
"fieldtype": "Date",
|
||||||
|
"label": "Next Billing Period End",
|
||||||
"no_copy": 1,
|
"no_copy": 1,
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
@@ -108,7 +138,18 @@
|
|||||||
"default": "0",
|
"default": "0",
|
||||||
"fieldname": "cancel_at_period_end",
|
"fieldname": "cancel_at_period_end",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Cancel At End Of Period"
|
"label": "Cancel When Period Ends"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"depends_on": "eval:!doc.__islocal",
|
||||||
|
"fieldname": "billing_history_section",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Billing History"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "billing_heatmap",
|
||||||
|
"fieldtype": "HTML",
|
||||||
|
"label": "Billing Heatmap"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"allow_on_submit": 1,
|
"allow_on_submit": 1,
|
||||||
@@ -206,7 +247,7 @@
|
|||||||
"description": "New invoices will be generated as per schedule even if current invoices are unpaid or past due date",
|
"description": "New invoices will be generated as per schedule even if current invoices are unpaid or past due date",
|
||||||
"fieldname": "generate_new_invoices_past_due_date",
|
"fieldname": "generate_new_invoices_past_due_date",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Generate New Invoices Past Due Date"
|
"label": "Bill Even If Previous Invoice Unpaid"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "end_date",
|
"fieldname": "end_date",
|
||||||
@@ -239,19 +280,23 @@
|
|||||||
"label": "Submit Generated Invoices"
|
"label": "Submit Generated Invoices"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "End of the current subscription period",
|
"default": "Postpaid (bill at period end)",
|
||||||
"fieldname": "generate_invoice_at",
|
"fieldname": "generate_invoice_at",
|
||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"label": "Generate Invoice At",
|
"label": "Generate Invoice At",
|
||||||
"options": "End of the current subscription period\nBeginning of the current subscription period\nDays before the current subscription period",
|
"options": "Postpaid (bill at period end)\nPrepaid (bill at period start)\nBill N days before period start",
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\"",
|
"depends_on": "eval:doc.generate_invoice_at === \"Bill N days before period start\"",
|
||||||
"fieldname": "number_of_days",
|
"fieldname": "number_of_days",
|
||||||
"fieldtype": "Int",
|
"fieldtype": "Int",
|
||||||
"label": "Number of Days",
|
"label": "Number of Days",
|
||||||
"mandatory_depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\""
|
"mandatory_depends_on": "eval:doc.generate_invoice_at === \"Bill N days before period start\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_jznv",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
@@ -267,11 +312,11 @@
|
|||||||
"link_fieldname": "subscription"
|
"link_fieldname": "subscription"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2025-12-23 19:42:52.036034",
|
"modified": "2026-06-04 07:21:15.938170",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Subscription",
|
"name": "Subscription",
|
||||||
"naming_rule": "Expression (old style)",
|
"naming_rule": "Expression",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from frappe.utils.data import (
|
|||||||
cint,
|
cint,
|
||||||
date_diff,
|
date_diff,
|
||||||
flt,
|
flt,
|
||||||
|
get_first_day,
|
||||||
get_last_day,
|
get_last_day,
|
||||||
get_link_to_form,
|
get_link_to_form,
|
||||||
getdate,
|
getdate,
|
||||||
@@ -35,6 +36,24 @@ class InvoiceNotCancelled(frappe.ValidationError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
GENERATE_AT_END = "Postpaid (bill at period end)"
|
||||||
|
GENERATE_AT_BEGINNING = "Prepaid (bill at period start)"
|
||||||
|
GENERATE_AT_DAYS_BEFORE = "Bill N days before period start"
|
||||||
|
|
||||||
|
STATUS_TRIALING = "Trialing"
|
||||||
|
STATUS_ACTIVE = "Active"
|
||||||
|
STATUS_GRACE_PERIOD = "Grace Period"
|
||||||
|
STATUS_CANCELLED = "Cancelled"
|
||||||
|
STATUS_UNPAID = "Unpaid"
|
||||||
|
STATUS_COMPLETED = "Completed"
|
||||||
|
STATUS_REFUNDED = "Refunded"
|
||||||
|
|
||||||
|
PARTY_CUSTOMER = "Customer"
|
||||||
|
PARTY_SUPPLIER = "Supplier"
|
||||||
|
|
||||||
|
INVOICE_PAID = "Paid"
|
||||||
|
|
||||||
|
|
||||||
DateTimeLikeObject = str | date
|
DateTimeLikeObject = str | date
|
||||||
|
|
||||||
|
|
||||||
@@ -64,11 +83,13 @@ class Subscription(Document):
|
|||||||
end_date: DF.Date | None
|
end_date: DF.Date | None
|
||||||
follow_calendar_months: DF.Check
|
follow_calendar_months: DF.Check
|
||||||
generate_invoice_at: DF.Literal[
|
generate_invoice_at: DF.Literal[
|
||||||
"End of the current subscription period",
|
"Postpaid (bill at period end)",
|
||||||
"Beginning of the current subscription period",
|
"Prepaid (bill at period start)",
|
||||||
"Days before the current subscription period",
|
"Bill N days before period start",
|
||||||
]
|
]
|
||||||
generate_new_invoices_past_due_date: DF.Check
|
generate_new_invoices_past_due_date: DF.Check
|
||||||
|
next_billing_period_end: DF.Date | None
|
||||||
|
next_billing_period_start: DF.Date | None
|
||||||
number_of_days: DF.Int
|
number_of_days: DF.Int
|
||||||
party: DF.DynamicLink
|
party: DF.DynamicLink
|
||||||
party_type: DF.Link
|
party_type: DF.Link
|
||||||
@@ -76,7 +97,9 @@ class Subscription(Document):
|
|||||||
purchase_tax_template: DF.Link | None
|
purchase_tax_template: DF.Link | None
|
||||||
sales_tax_template: DF.Link | None
|
sales_tax_template: DF.Link | None
|
||||||
start_date: DF.Date | None
|
start_date: DF.Date | None
|
||||||
status: DF.Literal["", "Trialing", "Active", "Grace Period", "Cancelled", "Unpaid", "Completed"]
|
status: DF.Literal[
|
||||||
|
"", "Trialing", "Active", "Grace Period", "Cancelled", "Unpaid", "Completed", "Refunded"
|
||||||
|
]
|
||||||
submit_invoice: DF.Check
|
submit_invoice: DF.Check
|
||||||
trial_period_end: DF.Date | None
|
trial_period_end: DF.Date | None
|
||||||
trial_period_start: DF.Date | None
|
trial_period_start: DF.Date | None
|
||||||
@@ -103,38 +126,39 @@ class Subscription(Document):
|
|||||||
or an outstanding invoice blocks billing (per `generate_new_invoices_past_due_date`).
|
or an outstanding invoice blocks billing (per `generate_new_invoices_past_due_date`).
|
||||||
"""
|
"""
|
||||||
while getdate(self._next_invoice_trigger_date()) <= getdate(nowdate()):
|
while getdate(self._next_invoice_trigger_date()) <= getdate(nowdate()):
|
||||||
period_start = self.current_invoice_start
|
period_start = self.next_billing_period_start
|
||||||
self.process(posting_date=self._next_invoice_trigger_date())
|
self.process(posting_date=self._next_invoice_trigger_date())
|
||||||
|
|
||||||
if self.status == "Cancelled" or getdate(self.current_invoice_start) == getdate(period_start):
|
if self.status == STATUS_CANCELLED or getdate(self.next_billing_period_start) == getdate(
|
||||||
|
period_start
|
||||||
|
):
|
||||||
break
|
break
|
||||||
|
|
||||||
if not self.generate_new_invoices_past_due_date:
|
if not self.generate_new_invoices_past_due_date:
|
||||||
break
|
break
|
||||||
|
|
||||||
def _next_invoice_trigger_date(self) -> DateTimeLikeObject:
|
def _next_invoice_trigger_date(self) -> DateTimeLikeObject:
|
||||||
if self.generate_invoice_at == "Beginning of the current subscription period":
|
return self._invoice_date_for_period(self.next_billing_period_start, self.next_billing_period_end)
|
||||||
return self.current_invoice_start
|
|
||||||
if self.generate_invoice_at == "Days before the current subscription period":
|
def _invoice_date_for_period(
|
||||||
return add_days(self.current_invoice_start, -self.number_of_days)
|
self, period_start: DateTimeLikeObject, period_end: DateTimeLikeObject
|
||||||
return self.current_invoice_end
|
) -> DateTimeLikeObject:
|
||||||
|
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
|
||||||
|
return period_start
|
||||||
|
if self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
|
||||||
|
return add_days(period_start, -self.number_of_days)
|
||||||
|
return period_end
|
||||||
|
|
||||||
def update_subscription_period(self, date: DateTimeLikeObject | None = None):
|
def update_subscription_period(self, date: DateTimeLikeObject | None = None):
|
||||||
"""
|
"""
|
||||||
Subscription period is the period to be billed. This method updates the
|
Subscription period is the period to be billed. This method updates the
|
||||||
beginning of the billing period and end of the billing period.
|
beginning of the billing period and end of the billing period.
|
||||||
The beginning of the billing period is represented in the doctype as
|
The beginning of the billing period is represented in the doctype as
|
||||||
`current_invoice_start` and the end of the billing period is represented
|
`next_billing_period_start` and the end of the billing period is represented
|
||||||
as `current_invoice_end`.
|
as `next_billing_period_end`.
|
||||||
"""
|
"""
|
||||||
self.current_invoice_start = self.get_current_invoice_start(date)
|
self.next_billing_period_start = self.get_current_invoice_start(date)
|
||||||
self.current_invoice_end = self.get_current_invoice_end(self.current_invoice_start)
|
self.next_billing_period_end = self.get_current_invoice_end(self.next_billing_period_start)
|
||||||
|
|
||||||
def _get_subscription_period(self, date: DateTimeLikeObject | None = None):
|
|
||||||
_current_invoice_start = self.get_current_invoice_start(date)
|
|
||||||
_current_invoice_end = self.get_current_invoice_end(_current_invoice_start)
|
|
||||||
|
|
||||||
return _current_invoice_start, _current_invoice_end
|
|
||||||
|
|
||||||
def get_current_invoice_start(self, date: DateTimeLikeObject | None = None) -> DateTimeLikeObject:
|
def get_current_invoice_start(self, date: DateTimeLikeObject | None = None) -> DateTimeLikeObject:
|
||||||
"""
|
"""
|
||||||
@@ -175,7 +199,7 @@ class Subscription(Document):
|
|||||||
_current_invoice_end = add_to_date(self.start_date, **billing_cycle_info)
|
_current_invoice_end = add_to_date(self.start_date, **billing_cycle_info)
|
||||||
|
|
||||||
# For cases where trial period is for an entire billing interval
|
# For cases where trial period is for an entire billing interval
|
||||||
if getdate(self.current_invoice_end) < getdate(date):
|
if getdate(self.next_billing_period_end) < getdate(date):
|
||||||
_current_invoice_end = add_to_date(date, **billing_cycle_info)
|
_current_invoice_end = add_to_date(date, **billing_cycle_info)
|
||||||
else:
|
else:
|
||||||
_current_invoice_end = add_to_date(date, **billing_cycle_info)
|
_current_invoice_end = add_to_date(date, **billing_cycle_info)
|
||||||
@@ -253,21 +277,35 @@ class Subscription(Document):
|
|||||||
"""
|
"""
|
||||||
Sets the status of the `Subscription`
|
Sets the status of the `Subscription`
|
||||||
"""
|
"""
|
||||||
|
self._set_current_invoice_dates()
|
||||||
if self.is_trialling():
|
if self.is_trialling():
|
||||||
self.status = "Trialing"
|
self.status = STATUS_TRIALING
|
||||||
|
elif self.is_fully_refunded() and self.has_outstanding_invoice():
|
||||||
|
self.status = STATUS_REFUNDED
|
||||||
elif (
|
elif (
|
||||||
not self.has_outstanding_invoice()
|
not self.has_outstanding_invoice()
|
||||||
and self.end_date
|
and self.end_date
|
||||||
and getdate(posting_date) > getdate(self.end_date)
|
and getdate(posting_date) > getdate(self.end_date)
|
||||||
):
|
):
|
||||||
self.status = "Completed"
|
self.status = STATUS_COMPLETED
|
||||||
elif self.is_past_grace_period():
|
elif self.is_past_grace_period():
|
||||||
self.status = self.get_status_for_past_grace_period()
|
self.status = self.get_status_for_past_grace_period()
|
||||||
self.cancelation_date = getdate(posting_date) if self.status == "Cancelled" else None
|
self.cancelation_date = getdate(posting_date) if self.status == STATUS_CANCELLED else None
|
||||||
elif self.current_invoice_is_past_due() and not self.is_past_grace_period():
|
elif self.current_invoice_is_past_due() and not self.is_past_grace_period():
|
||||||
self.status = "Grace Period"
|
self.status = STATUS_GRACE_PERIOD
|
||||||
elif not self.has_outstanding_invoice():
|
elif not self.has_outstanding_invoice():
|
||||||
self.status = "Active"
|
self.status = STATUS_ACTIVE
|
||||||
|
|
||||||
|
def _set_current_invoice_dates(self) -> None:
|
||||||
|
invoice = frappe.get_all(
|
||||||
|
self.invoice_document_type,
|
||||||
|
filters={"subscription": self.name, "docstatus": ("<", 2), "is_return": 0},
|
||||||
|
fields=["from_date", "to_date"],
|
||||||
|
order_by="to_date desc",
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
self.current_invoice_start = invoice[0].from_date if invoice else None
|
||||||
|
self.current_invoice_end = invoice[0].to_date if invoice else None
|
||||||
|
|
||||||
def is_trialling(self) -> bool:
|
def is_trialling(self) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -282,7 +320,6 @@ class Subscription(Document):
|
|||||||
"""
|
"""
|
||||||
Returns true if the given `end_date` has passed
|
Returns true if the given `end_date` has passed
|
||||||
"""
|
"""
|
||||||
# todo: test for illegal time
|
|
||||||
if not end_date:
|
if not end_date:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -290,10 +327,10 @@ class Subscription(Document):
|
|||||||
|
|
||||||
def get_status_for_past_grace_period(self) -> str:
|
def get_status_for_past_grace_period(self) -> str:
|
||||||
cancel_after_grace = cint(frappe.get_value("Subscription Settings", None, "cancel_after_grace"))
|
cancel_after_grace = cint(frappe.get_value("Subscription Settings", None, "cancel_after_grace"))
|
||||||
status = "Unpaid"
|
status = STATUS_UNPAID
|
||||||
|
|
||||||
if cancel_after_grace:
|
if cancel_after_grace:
|
||||||
status = "Cancelled"
|
status = STATUS_CANCELLED
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|
||||||
@@ -321,7 +358,7 @@ class Subscription(Document):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def invoice_document_type(self) -> str:
|
def invoice_document_type(self) -> str:
|
||||||
return "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
|
return "Sales Invoice" if self.party_type == PARTY_CUSTOMER else "Purchase Invoice"
|
||||||
|
|
||||||
def validate(self) -> None:
|
def validate(self) -> None:
|
||||||
self.validate_trial_period()
|
self.validate_trial_period()
|
||||||
@@ -413,11 +450,7 @@ class Subscription(Document):
|
|||||||
to_date: DateTimeLikeObject | None = None,
|
to_date: DateTimeLikeObject | None = None,
|
||||||
posting_date: DateTimeLikeObject | None = None,
|
posting_date: DateTimeLikeObject | None = None,
|
||||||
) -> Document:
|
) -> Document:
|
||||||
"""
|
"""Public alias for `create_invoice`; kept for external integrations."""
|
||||||
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, posting_date=posting_date)
|
return self.create_invoice(from_date=from_date, to_date=to_date, posting_date=posting_date)
|
||||||
|
|
||||||
def create_invoice(
|
def create_invoice(
|
||||||
@@ -429,8 +462,19 @@ class Subscription(Document):
|
|||||||
"""
|
"""
|
||||||
Creates a `Invoice`, submits it and returns it
|
Creates a `Invoice`, submits it and returns it
|
||||||
"""
|
"""
|
||||||
# For backward compatibility
|
company = self._resolve_company()
|
||||||
# Earlier subscription didn't had any company field
|
invoice = self._init_invoice_doc(company, posting_date)
|
||||||
|
self._set_invoice_party(invoice)
|
||||||
|
self._set_invoice_currency(invoice)
|
||||||
|
self._apply_accounting_dimensions(invoice)
|
||||||
|
self._append_invoice_items(invoice)
|
||||||
|
self._apply_taxes(invoice)
|
||||||
|
self._apply_payment_schedule(invoice)
|
||||||
|
self._apply_discounts(invoice)
|
||||||
|
return self._finalize_invoice(invoice, from_date, to_date)
|
||||||
|
|
||||||
|
def _resolve_company(self) -> str:
|
||||||
|
# Earlier subscriptions didn't have a company field
|
||||||
company = self.get("company") or get_default_company()
|
company = self.get("company") or get_default_company()
|
||||||
if not company:
|
if not company:
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
@@ -438,48 +482,49 @@ class Subscription(Document):
|
|||||||
"Company is mandatory for generating an invoice. Please set a default company in Global Defaults."
|
"Company is mandatory for generating an invoice. Please set a default company in Global Defaults."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
return company
|
||||||
|
|
||||||
|
def _init_invoice_doc(self, company: str, posting_date: DateTimeLikeObject | None = None) -> Document:
|
||||||
invoice = frappe.new_doc(self.invoice_document_type)
|
invoice = frappe.new_doc(self.invoice_document_type)
|
||||||
invoice.company = company
|
invoice.company = company
|
||||||
invoice.set_posting_time = 1
|
invoice.set_posting_time = 1
|
||||||
|
invoice.posting_date = self._invoice_posting_date(posting_date)
|
||||||
if self.generate_invoice_at == "Beginning of the current subscription period":
|
|
||||||
invoice.posting_date = self.current_invoice_start
|
|
||||||
elif self.generate_invoice_at == "Days before the current subscription period":
|
|
||||||
invoice.posting_date = posting_date or self.current_invoice_start
|
|
||||||
else:
|
|
||||||
invoice.posting_date = self.current_invoice_end
|
|
||||||
|
|
||||||
invoice.cost_center = self.cost_center
|
invoice.cost_center = self.cost_center
|
||||||
|
return invoice
|
||||||
|
|
||||||
|
def _invoice_posting_date(self, posting_date: DateTimeLikeObject | None = None) -> DateTimeLikeObject:
|
||||||
|
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
|
||||||
|
return self.next_billing_period_start
|
||||||
|
if self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
|
||||||
|
return posting_date or self.next_billing_period_start
|
||||||
|
return self.next_billing_period_end
|
||||||
|
|
||||||
|
def _set_invoice_party(self, invoice: Document) -> None:
|
||||||
if self.invoice_document_type == "Sales Invoice":
|
if self.invoice_document_type == "Sales Invoice":
|
||||||
invoice.customer = self.party
|
invoice.customer = self.party
|
||||||
else:
|
return
|
||||||
invoice.supplier = self.party
|
|
||||||
tax_withholding_category, tax_withholding_group = frappe.get_cached_value(
|
|
||||||
"Supplier", self.party, ["tax_withholding_category", "tax_withholding_group"]
|
|
||||||
)
|
|
||||||
if tax_withholding_category or tax_withholding_group:
|
|
||||||
invoice.apply_tds = 1
|
|
||||||
|
|
||||||
# Add currency to invoice
|
invoice.supplier = self.party
|
||||||
|
tax_withholding_category, tax_withholding_group = frappe.get_cached_value(
|
||||||
|
"Supplier", self.party, ["tax_withholding_category", "tax_withholding_group"]
|
||||||
|
)
|
||||||
|
if tax_withholding_category or tax_withholding_group:
|
||||||
|
invoice.apply_tds = 1
|
||||||
|
|
||||||
|
def _set_invoice_currency(self, invoice: Document) -> None:
|
||||||
invoice.currency = frappe.db.get_value("Subscription Plan", {"name": self.plans[0].plan}, "currency")
|
invoice.currency = frappe.db.get_value("Subscription Plan", {"name": self.plans[0].plan}, "currency")
|
||||||
|
|
||||||
# Add dimensions in invoice for subscription:
|
def _apply_accounting_dimensions(self, invoice: Document) -> None:
|
||||||
accounting_dimensions = get_accounting_dimensions()
|
for dimension in get_accounting_dimensions():
|
||||||
|
|
||||||
for dimension in accounting_dimensions:
|
|
||||||
if self.get(dimension):
|
if self.get(dimension):
|
||||||
invoice.update({dimension: self.get(dimension)})
|
invoice.update({dimension: self.get(dimension)})
|
||||||
|
|
||||||
# Subscription is better suited for service items. I won't update `update_stock`
|
def _append_invoice_items(self, invoice: Document) -> None:
|
||||||
# for that reason
|
# Subscription is better suited for service items, so `update_stock` is left untouched
|
||||||
items_list = self.get_items_from_plans(self.plans, is_prorate())
|
for item in self.get_items_from_plans(self.plans, is_prorate()):
|
||||||
|
|
||||||
for item in items_list:
|
|
||||||
invoice.append("items", item)
|
invoice.append("items", item)
|
||||||
|
|
||||||
# Taxes
|
def _apply_taxes(self, invoice: Document) -> None:
|
||||||
tax_template = ""
|
tax_template = ""
|
||||||
|
|
||||||
if self.invoice_document_type == "Sales Invoice" and self.sales_tax_template:
|
if self.invoice_document_type == "Sales Invoice" and self.sales_tax_template:
|
||||||
@@ -493,37 +538,43 @@ class Subscription(Document):
|
|||||||
invoice.taxes_and_charges = tax_template
|
invoice.taxes_and_charges = tax_template
|
||||||
TaxService(invoice).set_taxes()
|
TaxService(invoice).set_taxes()
|
||||||
|
|
||||||
# Due date
|
def _apply_payment_schedule(self, invoice: Document) -> None:
|
||||||
if self.days_until_due:
|
if not self.days_until_due:
|
||||||
invoice.append(
|
return
|
||||||
"payment_schedule",
|
|
||||||
{
|
|
||||||
"due_date": add_days(invoice.posting_date, cint(self.days_until_due)),
|
|
||||||
"invoice_portion": 100,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Discounts
|
invoice.append(
|
||||||
|
"payment_schedule",
|
||||||
|
{
|
||||||
|
"due_date": add_days(invoice.posting_date, cint(self.days_until_due)),
|
||||||
|
"invoice_portion": 100,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_discounts(self, invoice: Document) -> None:
|
||||||
if self.is_trialling():
|
if self.is_trialling():
|
||||||
invoice.additional_discount_percentage = 100
|
invoice.additional_discount_percentage = 100
|
||||||
else:
|
return
|
||||||
if self.additional_discount_percentage:
|
|
||||||
invoice.additional_discount_percentage = self.additional_discount_percentage
|
|
||||||
|
|
||||||
if self.additional_discount_amount:
|
if self.additional_discount_percentage:
|
||||||
invoice.discount_amount = self.additional_discount_amount
|
invoice.additional_discount_percentage = self.additional_discount_percentage
|
||||||
|
|
||||||
if self.additional_discount_percentage or self.additional_discount_amount:
|
if self.additional_discount_amount:
|
||||||
discount_on = self.apply_additional_discount
|
invoice.discount_amount = self.additional_discount_amount
|
||||||
invoice.apply_discount_on = discount_on if discount_on else "Grand Total"
|
|
||||||
|
|
||||||
# Subscription period
|
if self.additional_discount_percentage or self.additional_discount_amount:
|
||||||
|
invoice.apply_discount_on = self.apply_additional_discount or "Grand Total"
|
||||||
|
|
||||||
|
def _finalize_invoice(
|
||||||
|
self,
|
||||||
|
invoice: Document,
|
||||||
|
from_date: DateTimeLikeObject | None = None,
|
||||||
|
to_date: DateTimeLikeObject | None = None,
|
||||||
|
) -> Document:
|
||||||
invoice.subscription = self.name
|
invoice.subscription = self.name
|
||||||
invoice.from_date = from_date or self.current_invoice_start
|
invoice.from_date = from_date or self.next_billing_period_start
|
||||||
invoice.to_date = to_date or self.current_invoice_end
|
invoice.to_date = to_date or self.next_billing_period_end
|
||||||
|
|
||||||
invoice.flags.ignore_mandatory = True
|
invoice.flags.ignore_mandatory = True
|
||||||
|
|
||||||
invoice.set_missing_values()
|
invoice.set_missing_values()
|
||||||
invoice.save()
|
invoice.save()
|
||||||
|
|
||||||
@@ -540,15 +591,9 @@ class Subscription(Document):
|
|||||||
prorate_factor = 1
|
prorate_factor = 1
|
||||||
if prorate:
|
if prorate:
|
||||||
prorate_factor = get_prorata_factor(
|
prorate_factor = get_prorata_factor(
|
||||||
self.current_invoice_end,
|
self.next_billing_period_end,
|
||||||
self.current_invoice_start,
|
self.next_billing_period_start,
|
||||||
cint(
|
cint(self.generate_invoice_at in [GENERATE_AT_BEGINNING, GENERATE_AT_DAYS_BEFORE]),
|
||||||
self.generate_invoice_at
|
|
||||||
in [
|
|
||||||
"Beginning of the current subscription period",
|
|
||||||
"Days before the current subscription period",
|
|
||||||
]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
@@ -558,7 +603,7 @@ class Subscription(Document):
|
|||||||
|
|
||||||
item_code = plan_doc.item
|
item_code = plan_doc.item
|
||||||
|
|
||||||
if self.party_type == "Customer":
|
if self.party_type == PARTY_CUSTOMER:
|
||||||
deferred_field = "enable_deferred_revenue"
|
deferred_field = "enable_deferred_revenue"
|
||||||
else:
|
else:
|
||||||
deferred_field = "enable_deferred_expense"
|
deferred_field = "enable_deferred_expense"
|
||||||
@@ -572,8 +617,8 @@ class Subscription(Document):
|
|||||||
plan.plan,
|
plan.plan,
|
||||||
plan.qty,
|
plan.qty,
|
||||||
party,
|
party,
|
||||||
self.current_invoice_start,
|
self.next_billing_period_start,
|
||||||
self.current_invoice_end,
|
self.next_billing_period_end,
|
||||||
prorate_factor,
|
prorate_factor,
|
||||||
),
|
),
|
||||||
"cost_center": plan_doc.cost_center,
|
"cost_center": plan_doc.cost_center,
|
||||||
@@ -583,8 +628,8 @@ class Subscription(Document):
|
|||||||
item.update(
|
item.update(
|
||||||
{
|
{
|
||||||
deferred_field: deferred,
|
deferred_field: deferred,
|
||||||
"service_start_date": self.current_invoice_start,
|
"service_start_date": self.next_billing_period_start,
|
||||||
"service_end_date": self.current_invoice_end,
|
"service_end_date": self.next_billing_period_end,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -607,11 +652,11 @@ class Subscription(Document):
|
|||||||
2. `process_for_past_due`
|
2. `process_for_past_due`
|
||||||
"""
|
"""
|
||||||
if not self.is_current_invoice_generated(
|
if not self.is_current_invoice_generated(
|
||||||
self.current_invoice_start, self.current_invoice_end
|
self.next_billing_period_start, self.next_billing_period_end
|
||||||
) and self.can_generate_new_invoice(posting_date):
|
) and self.can_generate_new_invoice(posting_date):
|
||||||
self.generate_invoice(posting_date=posting_date)
|
self.generate_invoice(posting_date=posting_date)
|
||||||
if self.end_date:
|
if self.end_date:
|
||||||
next_start = add_days(self.current_invoice_end, 1)
|
next_start = add_days(self.next_billing_period_end, 1)
|
||||||
|
|
||||||
if getdate(next_start) > getdate(self.end_date):
|
if getdate(next_start) > getdate(self.end_date):
|
||||||
if self.cancel_at_period_end:
|
if self.cancel_at_period_end:
|
||||||
@@ -621,12 +666,12 @@ class Subscription(Document):
|
|||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
return
|
return
|
||||||
self.update_subscription_period(add_days(self.current_invoice_end, 1))
|
self.update_subscription_period(add_days(self.next_billing_period_end, 1))
|
||||||
elif posting_date and getdate(posting_date) > getdate(self.current_invoice_end):
|
elif posting_date and getdate(posting_date) > getdate(self.next_billing_period_end):
|
||||||
self.update_subscription_period()
|
self.update_subscription_period()
|
||||||
|
|
||||||
if self.cancel_at_period_end and (
|
if self.cancel_at_period_end and (
|
||||||
getdate(posting_date) >= getdate(self.current_invoice_end)
|
getdate(posting_date) >= getdate(self.next_billing_period_end)
|
||||||
or getdate(posting_date) >= getdate(self.end_date)
|
or getdate(posting_date) >= getdate(self.end_date)
|
||||||
):
|
):
|
||||||
self.cancel_subscription()
|
self.cancel_subscription()
|
||||||
@@ -652,9 +697,9 @@ class Subscription(Document):
|
|||||||
# multi-year gap doesn't retroactively bill cycle after cycle in one call.
|
# multi-year gap doesn't retroactively bill cycle after cycle in one call.
|
||||||
billing_cycle_info = self.get_billing_cycle_data()
|
billing_cycle_info = self.get_billing_cycle_data()
|
||||||
if billing_cycle_info:
|
if billing_cycle_info:
|
||||||
upper = getdate(add_to_date(self.current_invoice_end, **billing_cycle_info))
|
upper = getdate(add_to_date(self.next_billing_period_end, **billing_cycle_info))
|
||||||
else:
|
else:
|
||||||
upper = getdate(self.current_invoice_end)
|
upper = getdate(self.next_billing_period_end)
|
||||||
|
|
||||||
return posting <= upper
|
return posting <= upper
|
||||||
|
|
||||||
@@ -664,9 +709,8 @@ class Subscription(Document):
|
|||||||
_current_end_date: DateTimeLikeObject | None = None,
|
_current_end_date: DateTimeLikeObject | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not (_current_start_date and _current_end_date):
|
if not (_current_start_date and _current_end_date):
|
||||||
_current_start_date, _current_end_date = self._get_subscription_period(
|
_current_start_date = self.get_current_invoice_start(add_days(self.next_billing_period_end, 1))
|
||||||
date=add_days(self.current_invoice_end, 1)
|
_current_end_date = self.get_current_invoice_end(_current_start_date)
|
||||||
)
|
|
||||||
|
|
||||||
if self.current_invoice and getdate(_current_start_date) <= getdate(
|
if self.current_invoice and getdate(_current_start_date) <= getdate(
|
||||||
self.current_invoice.posting_date
|
self.current_invoice.posting_date
|
||||||
@@ -688,7 +732,7 @@ class Subscription(Document):
|
|||||||
"""
|
"""
|
||||||
invoice = frappe.get_all(
|
invoice = frappe.get_all(
|
||||||
self.invoice_document_type,
|
self.invoice_document_type,
|
||||||
{"subscription": self.name, "docstatus": ("<", 2)},
|
{"subscription": self.name, "docstatus": ("<", 2), "is_return": 0},
|
||||||
limit=1,
|
limit=1,
|
||||||
order_by="to_date desc",
|
order_by="to_date desc",
|
||||||
pluck="name",
|
pluck="name",
|
||||||
@@ -710,41 +754,70 @@ class Subscription(Document):
|
|||||||
"""
|
"""
|
||||||
Return `True` if the given invoice is paid
|
Return `True` if the given invoice is paid
|
||||||
"""
|
"""
|
||||||
return invoice.status == "Paid"
|
return invoice.status == INVOICE_PAID
|
||||||
|
|
||||||
def has_outstanding_invoice(self) -> int:
|
def has_outstanding_invoice(self) -> int:
|
||||||
"""
|
"""
|
||||||
Returns `True` if the most recent invoice for the `Subscription` is not paid
|
Returns the count of submitted, non-return invoices that are not yet paid.
|
||||||
"""
|
"""
|
||||||
return frappe.db.count(
|
return frappe.db.count(
|
||||||
self.invoice_document_type,
|
self.invoice_document_type,
|
||||||
{
|
{
|
||||||
"subscription": self.name,
|
"subscription": self.name,
|
||||||
"docstatus": 1,
|
"docstatus": 1,
|
||||||
"status": ["!=", "Paid"],
|
"is_return": 0,
|
||||||
|
"status": ["!=", INVOICE_PAID],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def is_fully_refunded(self) -> bool:
|
||||||
|
"""
|
||||||
|
`True` only when every submitted, not-`Paid` invoice on the subscription has
|
||||||
|
credit notes whose absolute total covers its outstanding amount.
|
||||||
|
"""
|
||||||
|
unpaid_invoices = frappe.get_all(
|
||||||
|
self.invoice_document_type,
|
||||||
|
filters={
|
||||||
|
"subscription": self.name,
|
||||||
|
"docstatus": 1,
|
||||||
|
"is_return": 0,
|
||||||
|
"status": ["!=", INVOICE_PAID],
|
||||||
|
},
|
||||||
|
fields=["name", "outstanding_amount"],
|
||||||
|
)
|
||||||
|
if not unpaid_invoices:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return all(self._is_invoice_fully_credited(invoice) for invoice in unpaid_invoices)
|
||||||
|
|
||||||
|
def _is_invoice_fully_credited(self, invoice: dict) -> bool:
|
||||||
|
credit_notes = frappe.get_all(
|
||||||
|
self.invoice_document_type,
|
||||||
|
filters={"return_against": invoice.name, "docstatus": 1},
|
||||||
|
pluck="grand_total",
|
||||||
|
)
|
||||||
|
credited = sum(flt(amount) for amount in credit_notes)
|
||||||
|
return abs(credited) >= flt(invoice.outstanding_amount)
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def cancel_subscription(self) -> None:
|
def cancel_subscription(self) -> None:
|
||||||
"""
|
"""
|
||||||
This sets the subscription as cancelled. It will stop invoices from being generated
|
This sets the subscription as cancelled. It will stop invoices from being generated
|
||||||
but it will not affect already created invoices.
|
but it will not affect already created invoices.
|
||||||
"""
|
"""
|
||||||
if self.status == "Cancelled":
|
if self.status == STATUS_CANCELLED:
|
||||||
frappe.throw(_("subscription is already cancelled."), InvoiceCancelled)
|
frappe.throw(_("subscription is already cancelled."), InvoiceCancelled)
|
||||||
|
|
||||||
to_generate_invoice = (
|
to_generate_invoice = (
|
||||||
True
|
True
|
||||||
if self.status == "Active"
|
if self.status == STATUS_ACTIVE and self.generate_invoice_at != GENERATE_AT_BEGINNING
|
||||||
and self.generate_invoice_at != "Beginning of the current subscription period"
|
|
||||||
else False
|
else False
|
||||||
)
|
)
|
||||||
self.status = "Cancelled"
|
self.status = STATUS_CANCELLED
|
||||||
self.cancelation_date = nowdate()
|
self.cancelation_date = nowdate()
|
||||||
|
|
||||||
if to_generate_invoice and getdate(self.cancelation_date) >= getdate(self.current_invoice_start):
|
if to_generate_invoice and getdate(self.cancelation_date) >= getdate(self.next_billing_period_start):
|
||||||
self.generate_invoice(self.current_invoice_start, self.cancelation_date)
|
self.generate_invoice(self.next_billing_period_start, self.cancelation_date)
|
||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
@@ -755,10 +828,10 @@ class Subscription(Document):
|
|||||||
subscription and the `Subscription` will lose all the history of generated invoices
|
subscription and the `Subscription` will lose all the history of generated invoices
|
||||||
it has.
|
it has.
|
||||||
"""
|
"""
|
||||||
if self.status != "Cancelled":
|
if self.status != STATUS_CANCELLED:
|
||||||
frappe.throw(_("You cannot restart a Subscription that is not cancelled."), InvoiceNotCancelled)
|
frappe.throw(_("You cannot restart a Subscription that is not cancelled."), InvoiceNotCancelled)
|
||||||
|
|
||||||
self.status = "Active"
|
self.status = STATUS_ACTIVE
|
||||||
self.cancelation_date = None
|
self.cancelation_date = None
|
||||||
self.update_subscription_period(posting_date or nowdate())
|
self.update_subscription_period(posting_date or nowdate())
|
||||||
self.save()
|
self.save()
|
||||||
@@ -766,25 +839,130 @@ class Subscription(Document):
|
|||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def force_fetch_subscription_updates(self):
|
def force_fetch_subscription_updates(self):
|
||||||
"""
|
"""
|
||||||
Process Subscription and create Invoices even if current date doesn't lie between current_invoice_start and currenct_invoice_end
|
Process Subscription and create Invoices even if current date doesn't lie between next_billing_period_start and next_billing_period_end
|
||||||
It makes use of 'Proces Subscription' to force processing in a specific 'posting_date'
|
It makes use of 'Proces Subscription' to force processing in a specific 'posting_date'
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Don't process future subscriptions
|
# Don't process future subscriptions
|
||||||
if getdate(nowdate()) < getdate(self.current_invoice_start):
|
if getdate(nowdate()) < getdate(self.next_billing_period_start):
|
||||||
frappe.msgprint(_("Subscription for Future dates cannot be processed."))
|
frappe.msgprint(_("Subscription for Future dates cannot be processed."))
|
||||||
return
|
return
|
||||||
|
|
||||||
processing_date = None
|
processing_date = None
|
||||||
if self.generate_invoice_at == "Beginning of the current subscription period":
|
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
|
||||||
processing_date = self.current_invoice_start
|
processing_date = self.next_billing_period_start
|
||||||
elif self.generate_invoice_at == "End of the current subscription period":
|
elif self.generate_invoice_at == GENERATE_AT_END:
|
||||||
processing_date = self.current_invoice_end
|
processing_date = self.next_billing_period_end
|
||||||
elif self.generate_invoice_at == "Days before the current subscription period":
|
elif self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
|
||||||
processing_date = add_days(self.current_invoice_start, -self.number_of_days)
|
processing_date = add_days(self.next_billing_period_start, -self.number_of_days)
|
||||||
|
|
||||||
self.process(posting_date=processing_date)
|
self.process(posting_date=processing_date)
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_billing_heatmap(self) -> list[dict]:
|
||||||
|
"""
|
||||||
|
One cell per calendar day for a fixed 12-month window starting at the first day of
|
||||||
|
the subscription's first month. Each day is coloured by the status of the billing
|
||||||
|
period it falls into; days with no invoice yet are `planned`.
|
||||||
|
"""
|
||||||
|
periods = self._billing_periods()
|
||||||
|
window_start = get_first_day(self.start_date) if self.start_date else get_first_day(nowdate())
|
||||||
|
window_end = get_last_day(add_months(window_start, 11))
|
||||||
|
|
||||||
|
cells = []
|
||||||
|
day = window_start
|
||||||
|
while day <= window_end:
|
||||||
|
cells.append(self._heatmap_cell(day, periods))
|
||||||
|
day = add_days(day, 1)
|
||||||
|
|
||||||
|
return cells
|
||||||
|
|
||||||
|
def _billing_periods(self) -> list[dict]:
|
||||||
|
invoices = frappe.get_all(
|
||||||
|
self.invoice_document_type,
|
||||||
|
filters={"subscription": self.name},
|
||||||
|
fields=[
|
||||||
|
"name",
|
||||||
|
"from_date",
|
||||||
|
"to_date",
|
||||||
|
"status",
|
||||||
|
"due_date",
|
||||||
|
"grand_total",
|
||||||
|
"docstatus",
|
||||||
|
"is_return",
|
||||||
|
"return_against",
|
||||||
|
],
|
||||||
|
order_by="from_date asc",
|
||||||
|
)
|
||||||
|
|
||||||
|
credited = {
|
||||||
|
invoice.return_against
|
||||||
|
for invoice in invoices
|
||||||
|
if invoice.is_return and invoice.docstatus == 1 and invoice.return_against
|
||||||
|
}
|
||||||
|
|
||||||
|
periods = [
|
||||||
|
{
|
||||||
|
"period_start": str(invoice.from_date),
|
||||||
|
"period_end": str(invoice.to_date),
|
||||||
|
"invoice": invoice.name,
|
||||||
|
"amount": flt(invoice.grand_total),
|
||||||
|
"status": self._heatmap_status(invoice, invoice.name in credited),
|
||||||
|
}
|
||||||
|
for invoice in invoices
|
||||||
|
if not invoice.is_return and invoice.from_date and invoice.to_date
|
||||||
|
]
|
||||||
|
|
||||||
|
return [*periods, *self._planned_periods(periods)]
|
||||||
|
|
||||||
|
def _heatmap_status(self, invoice: dict, is_credited: bool) -> str:
|
||||||
|
if invoice.docstatus == 2:
|
||||||
|
return "cancelled"
|
||||||
|
if is_credited:
|
||||||
|
return "refunded"
|
||||||
|
if invoice.status == INVOICE_PAID:
|
||||||
|
return "paid"
|
||||||
|
if invoice.due_date and getdate(invoice.due_date) < getdate(nowdate()):
|
||||||
|
return "overdue"
|
||||||
|
return "unpaid"
|
||||||
|
|
||||||
|
def _planned_periods(self, invoiced_periods: list[dict]) -> list[dict]:
|
||||||
|
invoiced = {(period["period_start"], period["period_end"]) for period in invoiced_periods}
|
||||||
|
planned = []
|
||||||
|
for start, end in self._upcoming_periods():
|
||||||
|
if start and end and (str(start), str(end)) not in invoiced:
|
||||||
|
planned.append(
|
||||||
|
{
|
||||||
|
"period_start": str(start),
|
||||||
|
"period_end": str(end),
|
||||||
|
"invoice": None,
|
||||||
|
"amount": 0.0,
|
||||||
|
"status": "planned",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return planned
|
||||||
|
|
||||||
|
def _upcoming_periods(self) -> list[tuple]:
|
||||||
|
"""The open billing period and the one immediately after it."""
|
||||||
|
open_period = (self.next_billing_period_start, self.next_billing_period_end)
|
||||||
|
after_start = add_days(self.next_billing_period_end, 1) if self.next_billing_period_end else None
|
||||||
|
after_end = self.get_current_invoice_end(after_start) if after_start else None
|
||||||
|
return [open_period, (after_start, after_end)]
|
||||||
|
|
||||||
|
def _heatmap_cell(self, day: date, periods: list[dict]) -> dict:
|
||||||
|
for period in periods:
|
||||||
|
if getdate(period["period_start"]) <= day <= getdate(period["period_end"]):
|
||||||
|
return {"date": str(day), **period}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"date": str(day),
|
||||||
|
"status": "planned",
|
||||||
|
"invoice": None,
|
||||||
|
"amount": 0.0,
|
||||||
|
"period_start": None,
|
||||||
|
"period_end": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_prorate() -> int:
|
def is_prorate() -> int:
|
||||||
return cint(frappe.db.get_single_value("Subscription Settings", "prorate"))
|
return cint(frappe.db.get_single_value("Subscription Settings", "prorate"))
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from frappe.utils.data import (
|
|||||||
date_diff,
|
date_diff,
|
||||||
flt,
|
flt,
|
||||||
get_date_str,
|
get_date_str,
|
||||||
|
get_first_day,
|
||||||
|
get_last_day,
|
||||||
getdate,
|
getdate,
|
||||||
nowdate,
|
nowdate,
|
||||||
)
|
)
|
||||||
@@ -35,11 +37,11 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
self.assertEqual(subscription.trial_period_start, nowdate())
|
self.assertEqual(subscription.trial_period_start, nowdate())
|
||||||
self.assertEqual(subscription.trial_period_end, add_months(nowdate(), 1))
|
self.assertEqual(subscription.trial_period_end, add_months(nowdate(), 1))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
add_days(subscription.trial_period_end, 1), get_date_str(subscription.current_invoice_start)
|
add_days(subscription.trial_period_end, 1), get_date_str(subscription.next_billing_period_start)
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
add_to_date(subscription.current_invoice_start, months=1, days=-1),
|
add_to_date(subscription.next_billing_period_start, months=1, days=-1),
|
||||||
get_date_str(subscription.current_invoice_end),
|
get_date_str(subscription.next_billing_period_end),
|
||||||
)
|
)
|
||||||
self.assertEqual(subscription.invoices, [])
|
self.assertEqual(subscription.invoices, [])
|
||||||
self.assertEqual(subscription.status, "Trialing")
|
self.assertEqual(subscription.status, "Trialing")
|
||||||
@@ -48,8 +50,8 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
subscription = create_subscription()
|
subscription = create_subscription()
|
||||||
self.assertEqual(subscription.trial_period_start, None)
|
self.assertEqual(subscription.trial_period_start, None)
|
||||||
self.assertEqual(subscription.trial_period_end, None)
|
self.assertEqual(subscription.trial_period_end, None)
|
||||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||||
# No invoice is created
|
# No invoice is created
|
||||||
self.assertEqual(len(subscription.invoices), 0)
|
self.assertEqual(len(subscription.invoices), 0)
|
||||||
self.assertEqual(subscription.status, "Active")
|
self.assertEqual(subscription.status, "Active")
|
||||||
@@ -66,12 +68,12 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
subscription = create_subscription(start_date="2018-01-01")
|
subscription = create_subscription(start_date="2018-01-01")
|
||||||
self.assertEqual(len(subscription.invoices), 1)
|
self.assertEqual(len(subscription.invoices), 1)
|
||||||
self.assertEqual(subscription.status, "Unpaid")
|
self.assertEqual(subscription.status, "Unpaid")
|
||||||
self.assertEqual(getdate(subscription.current_invoice_start), getdate("2018-02-01"))
|
self.assertEqual(getdate(subscription.next_billing_period_start), getdate("2018-02-01"))
|
||||||
self.assertEqual(getdate(subscription.current_invoice_end), getdate("2018-02-28"))
|
self.assertEqual(getdate(subscription.next_billing_period_end), getdate("2018-02-28"))
|
||||||
|
|
||||||
def test_status_goes_back_to_active_after_invoice_is_paid(self):
|
def test_status_goes_back_to_active_after_invoice_is_paid(self):
|
||||||
subscription = create_subscription(
|
subscription = create_subscription(
|
||||||
start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
|
start_date="2018-01-01", generate_invoice_at="Prepaid (bill at period start)"
|
||||||
)
|
)
|
||||||
subscription.process(posting_date="2018-01-01") # generate first invoice
|
subscription.process(posting_date="2018-01-01") # generate first invoice
|
||||||
self.assertEqual(len(subscription.invoices), 1)
|
self.assertEqual(len(subscription.invoices), 1)
|
||||||
@@ -89,7 +91,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
subscription.process()
|
subscription.process()
|
||||||
|
|
||||||
self.assertEqual(subscription.status, "Active")
|
self.assertEqual(subscription.status, "Active")
|
||||||
self.assertEqual(subscription.current_invoice_start, add_months(subscription.start_date, 1))
|
self.assertEqual(subscription.next_billing_period_start, add_months(subscription.start_date, 1))
|
||||||
self.assertEqual(len(subscription.invoices), 1)
|
self.assertEqual(len(subscription.invoices), 1)
|
||||||
|
|
||||||
def test_subscription_cancel_after_grace_period(self):
|
def test_subscription_cancel_after_grace_period(self):
|
||||||
@@ -122,7 +124,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
_date = add_months(nowdate(), -1)
|
_date = add_months(nowdate(), -1)
|
||||||
subscription = create_subscription(start_date=_date, days_until_due=10)
|
subscription = create_subscription(start_date=_date, days_until_due=10)
|
||||||
|
|
||||||
subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
|
subscription.process(posting_date=subscription.next_billing_period_end) # generate first invoice
|
||||||
self.assertEqual(len(subscription.invoices), 1)
|
self.assertEqual(len(subscription.invoices), 1)
|
||||||
self.assertEqual(subscription.status, "Active")
|
self.assertEqual(subscription.status, "Active")
|
||||||
|
|
||||||
@@ -134,7 +136,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
|
|
||||||
subscription = create_subscription(start_date=add_days(nowdate(), -1000))
|
subscription = create_subscription(start_date=add_days(nowdate(), -1000))
|
||||||
|
|
||||||
subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
|
subscription.process(posting_date=subscription.next_billing_period_end) # generate first invoice
|
||||||
self.assertEqual(subscription.status, "Grace Period")
|
self.assertEqual(subscription.status, "Grace Period")
|
||||||
|
|
||||||
subscription.process()
|
subscription.process()
|
||||||
@@ -154,20 +156,20 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
subscription = create_subscription() # no changes expected
|
subscription = create_subscription() # no changes expected
|
||||||
|
|
||||||
self.assertEqual(subscription.status, "Active")
|
self.assertEqual(subscription.status, "Active")
|
||||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||||
self.assertEqual(len(subscription.invoices), 0)
|
self.assertEqual(len(subscription.invoices), 0)
|
||||||
|
|
||||||
subscription.process() # no changes expected still
|
subscription.process() # no changes expected still
|
||||||
self.assertEqual(subscription.status, "Active")
|
self.assertEqual(subscription.status, "Active")
|
||||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||||
self.assertEqual(len(subscription.invoices), 0)
|
self.assertEqual(len(subscription.invoices), 0)
|
||||||
|
|
||||||
subscription.process() # no changes expected yet still
|
subscription.process() # no changes expected yet still
|
||||||
self.assertEqual(subscription.status, "Active")
|
self.assertEqual(subscription.status, "Active")
|
||||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||||
self.assertEqual(len(subscription.invoices), 0)
|
self.assertEqual(len(subscription.invoices), 0)
|
||||||
|
|
||||||
def test_subscription_cancellation(self):
|
def test_subscription_cancellation(self):
|
||||||
@@ -191,16 +193,18 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
self.assertEqual(len(subscription.invoices), 1)
|
self.assertEqual(len(subscription.invoices), 1)
|
||||||
|
|
||||||
invoice = subscription.get_current_invoice()
|
invoice = subscription.get_current_invoice()
|
||||||
diff = flt(date_diff(nowdate(), subscription.current_invoice_start) + 1)
|
diff = flt(date_diff(nowdate(), subscription.next_billing_period_start) + 1)
|
||||||
plan_days = flt(date_diff(subscription.current_invoice_end, subscription.current_invoice_start) + 1)
|
plan_days = flt(
|
||||||
|
date_diff(subscription.next_billing_period_end, subscription.next_billing_period_start) + 1
|
||||||
|
)
|
||||||
prorate_factor = flt(diff / plan_days)
|
prorate_factor = flt(diff / plan_days)
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
flt(
|
flt(
|
||||||
get_prorata_factor(
|
get_prorata_factor(
|
||||||
subscription.current_invoice_end,
|
subscription.next_billing_period_end,
|
||||||
subscription.current_invoice_start,
|
subscription.next_billing_period_start,
|
||||||
cint(subscription.generate_invoice_at == "Beginning of the current subscription period"),
|
cint(subscription.generate_invoice_at == "Prepaid (bill at period start)"),
|
||||||
),
|
),
|
||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
@@ -237,8 +241,10 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
subscription.cancel_subscription()
|
subscription.cancel_subscription()
|
||||||
|
|
||||||
invoice = subscription.get_current_invoice()
|
invoice = subscription.get_current_invoice()
|
||||||
diff = flt(date_diff(nowdate(), subscription.current_invoice_start) + 1)
|
diff = flt(date_diff(nowdate(), subscription.next_billing_period_start) + 1)
|
||||||
plan_days = flt(date_diff(subscription.current_invoice_end, subscription.current_invoice_start) + 1)
|
plan_days = flt(
|
||||||
|
date_diff(subscription.next_billing_period_end, subscription.next_billing_period_start) + 1
|
||||||
|
)
|
||||||
prorate_factor = flt(diff / plan_days)
|
prorate_factor = flt(diff / plan_days)
|
||||||
|
|
||||||
self.assertEqual(flt(invoice.grand_total, 2), flt(prorate_factor * 900, 2))
|
self.assertEqual(flt(invoice.grand_total, 2), flt(prorate_factor * 900, 2))
|
||||||
@@ -303,9 +309,9 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
settings.save()
|
settings.save()
|
||||||
|
|
||||||
subscription = create_subscription(
|
subscription = create_subscription(
|
||||||
start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
|
start_date="2018-01-01", generate_invoice_at="Prepaid (bill at period start)"
|
||||||
)
|
)
|
||||||
subscription.process(subscription.current_invoice_start) # generate first invoice
|
subscription.process(subscription.next_billing_period_start) # generate first invoice
|
||||||
# This should change status to Unpaid since grace period is 0
|
# This should change status to Unpaid since grace period is 0
|
||||||
self.assertEqual(subscription.status, "Unpaid")
|
self.assertEqual(subscription.status, "Unpaid")
|
||||||
|
|
||||||
@@ -317,7 +323,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
self.assertEqual(subscription.status, "Active")
|
self.assertEqual(subscription.status, "Active")
|
||||||
|
|
||||||
# A new invoice is generated
|
# A new invoice is generated
|
||||||
subscription.process(posting_date=subscription.current_invoice_start)
|
subscription.process(posting_date=subscription.next_billing_period_start)
|
||||||
self.assertEqual(subscription.status, "Unpaid")
|
self.assertEqual(subscription.status, "Unpaid")
|
||||||
|
|
||||||
settings.cancel_after_grace = default_grace_period_action
|
settings.cancel_after_grace = default_grace_period_action
|
||||||
@@ -354,7 +360,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
|
|
||||||
# Change the subscription type to prebilled and process it.
|
# Change the subscription type to prebilled and process it.
|
||||||
# Prepaid invoice should be generated
|
# Prepaid invoice should be generated
|
||||||
subscription.generate_invoice_at = "Beginning of the current subscription period"
|
subscription.generate_invoice_at = "Prepaid (bill at period start)"
|
||||||
subscription.save()
|
subscription.save()
|
||||||
subscription.process()
|
subscription.process()
|
||||||
|
|
||||||
@@ -366,7 +372,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
settings.prorate = 1
|
settings.prorate = 1
|
||||||
settings.save()
|
settings.save()
|
||||||
|
|
||||||
subscription = create_subscription(generate_invoice_at="Beginning of the current subscription period")
|
subscription = create_subscription(generate_invoice_at="Prepaid (bill at period start)")
|
||||||
subscription.process()
|
subscription.process()
|
||||||
subscription.cancel_subscription()
|
subscription.cancel_subscription()
|
||||||
|
|
||||||
@@ -387,7 +393,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
subscription.company = "_Test Company"
|
subscription.company = "_Test Company"
|
||||||
subscription.party_type = "Supplier"
|
subscription.party_type = "Supplier"
|
||||||
subscription.party = "_Test Supplier"
|
subscription.party = "_Test Supplier"
|
||||||
subscription.generate_invoice_at = "Beginning of the current subscription period"
|
subscription.generate_invoice_at = "Prepaid (bill at period start)"
|
||||||
subscription.follow_calendar_months = 1
|
subscription.follow_calendar_months = 1
|
||||||
|
|
||||||
# select subscription start date as "2018-01-15"
|
# select subscription start date as "2018-01-15"
|
||||||
@@ -413,7 +419,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
end_date="2018-12-31",
|
end_date="2018-12-31",
|
||||||
party_type="Supplier",
|
party_type="Supplier",
|
||||||
party="_Test Supplier",
|
party="_Test Supplier",
|
||||||
generate_invoice_at="Beginning of the current subscription period",
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
generate_new_invoices_past_due_date=1,
|
generate_new_invoices_past_due_date=1,
|
||||||
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
|
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
|
||||||
)
|
)
|
||||||
@@ -424,7 +430,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
def test_subscription_without_generate_invoice_past_due(self):
|
def test_subscription_without_generate_invoice_past_due(self):
|
||||||
subscription = create_subscription(
|
subscription = create_subscription(
|
||||||
start_date="2018-01-01",
|
start_date="2018-01-01",
|
||||||
generate_invoice_at="Beginning of the current subscription period",
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
|
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -442,7 +448,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
frappe.db.set_value("Customer", party, "default_currency", "USD")
|
frappe.db.set_value("Customer", party, "default_currency", "USD")
|
||||||
subscription = create_subscription(
|
subscription = create_subscription(
|
||||||
start_date="2018-01-01",
|
start_date="2018-01-01",
|
||||||
generate_invoice_at="Beginning of the current subscription period",
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
plans=[{"plan": "_Test Plan Multicurrency", "qty": 1, "currency": "USD"}],
|
plans=[{"plan": "_Test Plan Multicurrency", "qty": 1, "currency": "USD"}],
|
||||||
party=party,
|
party=party,
|
||||||
)
|
)
|
||||||
@@ -464,7 +470,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
frappe.db.set_value("Customer", party, "default_currency", "USD")
|
frappe.db.set_value("Customer", party, "default_currency", "USD")
|
||||||
subscription = create_subscription(
|
subscription = create_subscription(
|
||||||
start_date="2018-01-01",
|
start_date="2018-01-01",
|
||||||
generate_invoice_at="Beginning of the current subscription period",
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
plans=[{"plan": "_Test Plan Multicurrency", "qty": 1, "currency": "USD"}],
|
plans=[{"plan": "_Test Plan Multicurrency", "qty": 1, "currency": "USD"}],
|
||||||
party=party,
|
party=party,
|
||||||
)
|
)
|
||||||
@@ -517,7 +523,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
subscription = create_subscription(
|
subscription = create_subscription(
|
||||||
start_date="2023-01-01",
|
start_date="2023-01-01",
|
||||||
end_date="2023-02-28",
|
end_date="2023-02-28",
|
||||||
generate_invoice_at="Days before the current subscription period",
|
generate_invoice_at="Bill N days before period start",
|
||||||
number_of_days=10,
|
number_of_days=10,
|
||||||
generate_new_invoices_past_due_date=1,
|
generate_new_invoices_past_due_date=1,
|
||||||
)
|
)
|
||||||
@@ -555,7 +561,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
party_type="Supplier",
|
party_type="Supplier",
|
||||||
party="_Test Supplier",
|
party="_Test Supplier",
|
||||||
generate_invoice_at="Days before the current subscription period",
|
generate_invoice_at="Bill N days before period start",
|
||||||
generate_new_invoices_past_due_date=1,
|
generate_new_invoices_past_due_date=1,
|
||||||
number_of_days=2,
|
number_of_days=2,
|
||||||
plans=[{"plan": "_Test Plan Name 5", "qty": 1}],
|
plans=[{"plan": "_Test Plan Name 5", "qty": 1}],
|
||||||
@@ -577,7 +583,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
end_date=add_days(start_date, 8),
|
end_date=add_days(start_date, 8),
|
||||||
cancel_at_period_end=1,
|
cancel_at_period_end=1,
|
||||||
generate_new_invoices_past_due_date=1,
|
generate_new_invoices_past_due_date=1,
|
||||||
generate_invoice_at="Beginning of the current subscription period",
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
plans=[{"plan": "_Test plan name 10", "qty": 1}],
|
plans=[{"plan": "_Test plan name 10", "qty": 1}],
|
||||||
)
|
)
|
||||||
# Catch-up billing on creation generates every elapsed period and cancels at end
|
# Catch-up billing on creation generates every elapsed period and cancels at end
|
||||||
@@ -598,7 +604,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
end_date=add_days(start_date, 6),
|
end_date=add_days(start_date, 6),
|
||||||
cancel_at_period_end=1,
|
cancel_at_period_end=1,
|
||||||
generate_new_invoices_past_due_date=1,
|
generate_new_invoices_past_due_date=1,
|
||||||
generate_invoice_at="Beginning of the current subscription period",
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
plans=[{"plan": "_Test plan name 10", "qty": 1}],
|
plans=[{"plan": "_Test plan name 10", "qty": 1}],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -684,7 +690,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
party_type="Customer",
|
party_type="Customer",
|
||||||
party="_Test Customer",
|
party="_Test Customer",
|
||||||
generate_invoice_at="Beginning of the current subscription period",
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
generate_new_invoices_past_due_date=1,
|
generate_new_invoices_past_due_date=1,
|
||||||
plans=[{"plan": "_Test Plan 3 Day", "qty": 1}],
|
plans=[{"plan": "_Test Plan 3 Day", "qty": 1}],
|
||||||
)
|
)
|
||||||
@@ -713,7 +719,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
def test_status_updates_immediately_when_invoice_paid(self):
|
def test_status_updates_immediately_when_invoice_paid(self):
|
||||||
subscription = create_subscription(
|
subscription = create_subscription(
|
||||||
start_date=nowdate(),
|
start_date=nowdate(),
|
||||||
generate_invoice_at="Beginning of the current subscription period",
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
submit_invoice=1,
|
submit_invoice=1,
|
||||||
)
|
)
|
||||||
subscription.process(posting_date=nowdate())
|
subscription.process(posting_date=nowdate())
|
||||||
@@ -729,7 +735,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
def test_invoice_update_hook_refreshes_subscription_status(self):
|
def test_invoice_update_hook_refreshes_subscription_status(self):
|
||||||
subscription = create_subscription(
|
subscription = create_subscription(
|
||||||
start_date=nowdate(),
|
start_date=nowdate(),
|
||||||
generate_invoice_at="Beginning of the current subscription period",
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
submit_invoice=1,
|
submit_invoice=1,
|
||||||
)
|
)
|
||||||
subscription.process(posting_date=nowdate())
|
subscription.process(posting_date=nowdate())
|
||||||
@@ -748,7 +754,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
# Test that payment entry → invoice → subscription status update chain works
|
# Test that payment entry → invoice → subscription status update chain works
|
||||||
subscription = create_subscription(
|
subscription = create_subscription(
|
||||||
start_date=nowdate(),
|
start_date=nowdate(),
|
||||||
generate_invoice_at="Beginning of the current subscription period",
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
submit_invoice=1,
|
submit_invoice=1,
|
||||||
)
|
)
|
||||||
subscription.process(posting_date=nowdate())
|
subscription.process(posting_date=nowdate())
|
||||||
@@ -771,16 +777,33 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
def test_first_invoice_generated_on_create_for_prepaid(self):
|
def test_first_invoice_generated_on_create_for_prepaid(self):
|
||||||
subscription = create_subscription(
|
subscription = create_subscription(
|
||||||
start_date=nowdate(),
|
start_date=nowdate(),
|
||||||
generate_invoice_at="Beginning of the current subscription period",
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
)
|
)
|
||||||
self.assertEqual(len(subscription.invoices), 1)
|
self.assertEqual(len(subscription.invoices), 1)
|
||||||
|
|
||||||
|
def test_current_invoice_dates_reflect_latest_invoice(self):
|
||||||
|
subscription = create_subscription(
|
||||||
|
start_date="2018-01-01",
|
||||||
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
|
submit_invoice=1,
|
||||||
|
)
|
||||||
|
subscription.process(posting_date="2018-01-01")
|
||||||
|
invoice = subscription.get_current_invoice()
|
||||||
|
|
||||||
|
subscription.reload()
|
||||||
|
self.assertEqual(getdate(subscription.current_invoice_start), getdate(invoice.from_date))
|
||||||
|
self.assertEqual(getdate(subscription.current_invoice_end), getdate(invoice.to_date))
|
||||||
|
# `next_billing_period_start` tracks the next (unbilled) period.
|
||||||
|
self.assertEqual(
|
||||||
|
getdate(subscription.next_billing_period_start), getdate(add_days(invoice.to_date, 1))
|
||||||
|
)
|
||||||
|
|
||||||
def test_first_invoice_not_generated_on_create_during_trial(self):
|
def test_first_invoice_not_generated_on_create_during_trial(self):
|
||||||
subscription = create_subscription(
|
subscription = create_subscription(
|
||||||
start_date=nowdate(),
|
start_date=nowdate(),
|
||||||
trial_period_start=nowdate(),
|
trial_period_start=nowdate(),
|
||||||
trial_period_end=add_days(nowdate(), 30),
|
trial_period_end=add_days(nowdate(), 30),
|
||||||
generate_invoice_at="Beginning of the current subscription period",
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
)
|
)
|
||||||
self.assertEqual(len(subscription.invoices), 0)
|
self.assertEqual(len(subscription.invoices), 0)
|
||||||
self.assertEqual(subscription.status, "Trialing")
|
self.assertEqual(subscription.status, "Trialing")
|
||||||
@@ -790,7 +813,7 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
try:
|
try:
|
||||||
subscription = create_subscription(
|
subscription = create_subscription(
|
||||||
start_date=nowdate(),
|
start_date=nowdate(),
|
||||||
generate_invoice_at="Beginning of the current subscription period",
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
)
|
)
|
||||||
self.assertEqual(len(subscription.invoices), 0)
|
self.assertEqual(len(subscription.invoices), 0)
|
||||||
finally:
|
finally:
|
||||||
@@ -799,10 +822,144 @@ class TestSubscription(ERPNextTestSuite):
|
|||||||
def test_first_invoice_not_generated_for_future_dated_subscription(self):
|
def test_first_invoice_not_generated_for_future_dated_subscription(self):
|
||||||
subscription = create_subscription(
|
subscription = create_subscription(
|
||||||
start_date=add_days(nowdate(), 10),
|
start_date=add_days(nowdate(), 10),
|
||||||
generate_invoice_at="Beginning of the current subscription period",
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
)
|
)
|
||||||
self.assertEqual(len(subscription.invoices), 0)
|
self.assertEqual(len(subscription.invoices), 0)
|
||||||
|
|
||||||
|
def test_generate_invoice_at_migration_patch(self):
|
||||||
|
from erpnext.patches.v16_0.migrate_subscription_generate_invoice_at import VALUE_MAP, execute
|
||||||
|
|
||||||
|
subscription = create_subscription(start_date=add_days(nowdate(), 10))
|
||||||
|
for old_value, new_value in VALUE_MAP.items():
|
||||||
|
frappe.db.set_value("Subscription", subscription.name, "generate_invoice_at", old_value)
|
||||||
|
execute()
|
||||||
|
self.assertEqual(
|
||||||
|
frappe.db.get_value("Subscription", subscription.name, "generate_invoice_at"), new_value
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_next_billing_period_populated_for_prepaid(self):
|
||||||
|
subscription = create_subscription(
|
||||||
|
start_date=add_days(nowdate(), 10),
|
||||||
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
|
)
|
||||||
|
self.assertEqual(getdate(subscription.next_billing_period_start), getdate(add_days(nowdate(), 10)))
|
||||||
|
self.assertGreater(
|
||||||
|
getdate(subscription.next_billing_period_end), getdate(subscription.next_billing_period_start)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_status_becomes_refunded_when_only_invoice_credited(self):
|
||||||
|
subscription = create_subscription(
|
||||||
|
start_date=nowdate(),
|
||||||
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
|
submit_invoice=1,
|
||||||
|
)
|
||||||
|
subscription.process(posting_date=nowdate())
|
||||||
|
self.assertEqual(subscription.status, "Unpaid")
|
||||||
|
|
||||||
|
make_full_credit_note(subscription.get_current_invoice().name)
|
||||||
|
|
||||||
|
subscription.reload()
|
||||||
|
self.assertEqual(subscription.status, "Refunded")
|
||||||
|
|
||||||
|
def test_status_stays_unpaid_when_one_of_two_invoices_credited(self):
|
||||||
|
subscription = create_subscription(
|
||||||
|
start_date=add_months(nowdate(), -2),
|
||||||
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
|
submit_invoice=1,
|
||||||
|
generate_new_invoices_past_due_date=1,
|
||||||
|
)
|
||||||
|
invoices = frappe.get_all(
|
||||||
|
"Sales Invoice",
|
||||||
|
filters={"subscription": subscription.name, "docstatus": 1, "is_return": 0},
|
||||||
|
pluck="name",
|
||||||
|
order_by="from_date asc",
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(len(invoices), 2)
|
||||||
|
|
||||||
|
make_full_credit_note(invoices[0])
|
||||||
|
|
||||||
|
subscription.reload()
|
||||||
|
self.assertNotEqual(subscription.status, "Refunded")
|
||||||
|
|
||||||
|
def test_refunded_reverts_to_active_after_full_settlement(self):
|
||||||
|
subscription = create_subscription(
|
||||||
|
start_date=nowdate(),
|
||||||
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
|
submit_invoice=1,
|
||||||
|
)
|
||||||
|
subscription.process(posting_date=nowdate())
|
||||||
|
invoice = subscription.get_current_invoice()
|
||||||
|
make_full_credit_note(invoice.name)
|
||||||
|
|
||||||
|
subscription.reload()
|
||||||
|
self.assertEqual(subscription.status, "Refunded")
|
||||||
|
|
||||||
|
invoice.db_set("status", "Paid")
|
||||||
|
invoice.db_set("outstanding_amount", 0)
|
||||||
|
subscription.process()
|
||||||
|
self.assertEqual(subscription.status, "Active")
|
||||||
|
|
||||||
|
def test_heatmap_spans_twelve_months_from_start_month(self):
|
||||||
|
start_date = getdate("2024-03-14")
|
||||||
|
subscription = create_subscription(start_date=start_date)
|
||||||
|
heatmap = subscription.get_billing_heatmap()
|
||||||
|
self.assertEqual(getdate(heatmap[0]["date"]), get_first_day(start_date))
|
||||||
|
self.assertEqual(
|
||||||
|
getdate(heatmap[-1]["date"]), get_last_day(add_months(get_first_day(start_date), 11))
|
||||||
|
)
|
||||||
|
self.assertIn("status", heatmap[0])
|
||||||
|
|
||||||
|
def test_heatmap_marks_paid_days_green(self):
|
||||||
|
subscription = create_subscription(
|
||||||
|
start_date=nowdate(),
|
||||||
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
|
submit_invoice=1,
|
||||||
|
)
|
||||||
|
subscription.process(posting_date=nowdate())
|
||||||
|
invoice = subscription.get_current_invoice()
|
||||||
|
invoice.db_set("status", "Paid")
|
||||||
|
invoice.db_set("outstanding_amount", 0)
|
||||||
|
|
||||||
|
subscription.reload()
|
||||||
|
cells = {cell["date"]: cell for cell in subscription.get_billing_heatmap()}
|
||||||
|
self.assertEqual(cells[str(getdate(invoice.from_date))]["status"], "paid")
|
||||||
|
|
||||||
|
def test_heatmap_marks_future_planned_days(self):
|
||||||
|
subscription = create_subscription(
|
||||||
|
start_date=nowdate(),
|
||||||
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
|
)
|
||||||
|
today = getdate(nowdate())
|
||||||
|
planned = [
|
||||||
|
cell
|
||||||
|
for cell in subscription.get_billing_heatmap()
|
||||||
|
if cell["status"] == "planned" and getdate(cell["date"]) > today
|
||||||
|
]
|
||||||
|
self.assertTrue(planned)
|
||||||
|
|
||||||
|
def test_heatmap_marks_refunded_days_for_credited_periods(self):
|
||||||
|
subscription = create_subscription(
|
||||||
|
start_date=nowdate(),
|
||||||
|
generate_invoice_at="Prepaid (bill at period start)",
|
||||||
|
submit_invoice=1,
|
||||||
|
)
|
||||||
|
subscription.process(posting_date=nowdate())
|
||||||
|
invoice = subscription.get_current_invoice()
|
||||||
|
make_full_credit_note(invoice.name)
|
||||||
|
|
||||||
|
subscription.reload()
|
||||||
|
cells = {cell["date"]: cell for cell in subscription.get_billing_heatmap()}
|
||||||
|
self.assertEqual(cells[str(getdate(invoice.from_date))]["status"], "refunded")
|
||||||
|
|
||||||
|
|
||||||
|
def make_full_credit_note(invoice_name):
|
||||||
|
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
|
||||||
|
|
||||||
|
credit_note = make_sales_return(invoice_name)
|
||||||
|
credit_note.insert()
|
||||||
|
credit_note.submit()
|
||||||
|
return credit_note
|
||||||
|
|
||||||
|
|
||||||
def make_plans():
|
def make_plans():
|
||||||
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")
|
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")
|
||||||
|
|||||||
@@ -486,3 +486,5 @@ erpnext.patches.v16_0.clear_procedures_from_receivable_report
|
|||||||
erpnext.patches.v16_0.migrate_address_contact_custom_fields
|
erpnext.patches.v16_0.migrate_address_contact_custom_fields
|
||||||
erpnext.patches.v16_0.rename_secondary_item_type_field
|
erpnext.patches.v16_0.rename_secondary_item_type_field
|
||||||
erpnext.patches.v16_0.submit_existing_product_bundles #1
|
erpnext.patches.v16_0.submit_existing_product_bundles #1
|
||||||
|
erpnext.patches.v16_0.migrate_subscription_generate_invoice_at
|
||||||
|
erpnext.patches.v16_0.rename_subscription_billing_period_fields
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import frappe
|
||||||
|
|
||||||
|
VALUE_MAP = {
|
||||||
|
"End of the current subscription period": "Postpaid (bill at period end)",
|
||||||
|
"Beginning of the current subscription period": "Prepaid (bill at period start)",
|
||||||
|
"Days before the current subscription period": "Bill N days before period start",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
subscription = frappe.qb.DocType("Subscription")
|
||||||
|
for old_value, new_value in VALUE_MAP.items():
|
||||||
|
(
|
||||||
|
frappe.qb.update(subscription)
|
||||||
|
.set(subscription.generate_invoice_at, new_value)
|
||||||
|
.where(subscription.generate_invoice_at == old_value)
|
||||||
|
).run()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import frappe
|
||||||
|
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
"""Move billing-period data to the renamed fields.
|
||||||
|
|
||||||
|
`current_invoice_start/end` used to hold the open (next) billing period and now
|
||||||
|
holds the actual current invoice period, while the open period moved to
|
||||||
|
`next_billing_period_start/end`.
|
||||||
|
"""
|
||||||
|
columns = set(frappe.db.get_table_columns("Subscription"))
|
||||||
|
subscription = frappe.qb.DocType("Subscription")
|
||||||
|
|
||||||
|
if {"next_billing_period_start", "next_billing_period_end"} <= columns:
|
||||||
|
(
|
||||||
|
frappe.qb.update(subscription)
|
||||||
|
.set(subscription.next_billing_period_start, subscription.current_invoice_start)
|
||||||
|
.set(subscription.next_billing_period_end, subscription.current_invoice_end)
|
||||||
|
).run()
|
||||||
|
|
||||||
|
if {"current_invoice_from_date", "current_invoice_to_date"} <= columns:
|
||||||
|
(
|
||||||
|
frappe.qb.update(subscription)
|
||||||
|
.set(subscription.current_invoice_start, subscription.current_invoice_from_date)
|
||||||
|
.set(subscription.current_invoice_end, subscription.current_invoice_to_date)
|
||||||
|
).run()
|
||||||
Reference in New Issue
Block a user