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:
Jatin3128
2026-06-09 16:43:24 +05:30
committed by GitHub
parent ca77145522
commit 4c3aa9b4f3
9 changed files with 714 additions and 190 deletions

View File

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

View File

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

View File

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

View File

@@ -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": [
{ {

View File

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

View File

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

View File

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

View File

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

View File

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