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()
if self.is_return:
self.refresh_subscription_status()
def on_update_after_submit(self):
fields_to_check = [
"cash_bank_account",
@@ -772,6 +775,8 @@ class PurchaseInvoice(BuyingController):
"Tax Withholding Entry",
)
self.refresh_subscription_status()
def update_project(self):
projects = frappe._dict()
for d in self.items:

View File

@@ -497,6 +497,9 @@ class SalesInvoice(SellingController):
self.process_common_party_accounting()
self.update_billed_qty_in_scio()
if self.is_return:
self.refresh_subscription_status()
def before_cancel(self):
POSService(self).check_if_created_using_pos_and_pos_closing_entry_generated()
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()
self.update_billed_qty_in_scio()
self.refresh_subscription_status()
def update_status_updater_args(self):
if not cint(self.update_stock):

View File

@@ -29,7 +29,13 @@ frappe.ui.form.on("Subscription", {
},
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") {
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,
"engine": "InnoDB",
"field_order": [
"billing_history_section",
"billing_heatmap",
"section_break_jznv",
"party_type",
"party",
"cb_1",
@@ -21,12 +24,16 @@
"generate_new_invoices_past_due_date",
"submit_invoice",
"column_break_11",
"current_invoice_start",
"current_invoice_end",
"days_until_due",
"generate_invoice_at",
"number_of_days",
"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",
"plans",
"sb_1",
@@ -51,7 +58,7 @@
"fieldtype": "Select",
"label": "Status",
"no_copy": 1,
"options": "\nTrialing\nActive\nGrace Period\nCancelled\nUnpaid\nCompleted",
"options": "\nTrialing\nActive\nGrace Period\nCancelled\nUnpaid\nCompleted\nRefunded",
"read_only": 1
},
{
@@ -83,17 +90,40 @@
"fieldname": "column_break_11",
"fieldtype": "Column Break"
},
{
"fieldname": "billing_period_section",
"fieldtype": "Section Break",
"label": "Billing Period"
},
{
"fieldname": "current_invoice_start",
"fieldtype": "Date",
"label": "Current Invoice Start Date",
"label": "Current Invoice Start",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "current_invoice_end",
"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,
"read_only": 1
},
@@ -108,7 +138,18 @@
"default": "0",
"fieldname": "cancel_at_period_end",
"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,
@@ -206,7 +247,7 @@
"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",
"fieldtype": "Check",
"label": "Generate New Invoices Past Due Date"
"label": "Bill Even If Previous Invoice Unpaid"
},
{
"fieldname": "end_date",
@@ -239,19 +280,23 @@
"label": "Submit Generated Invoices"
},
{
"default": "End of the current subscription period",
"default": "Postpaid (bill at period end)",
"fieldname": "generate_invoice_at",
"fieldtype": "Select",
"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
},
{
"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",
"fieldtype": "Int",
"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,
@@ -267,11 +312,11 @@
"link_fieldname": "subscription"
}
],
"modified": "2025-12-23 19:42:52.036034",
"modified": "2026-06-04 07:21:15.938170",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Subscription",
"naming_rule": "Expression (old style)",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{

View File

@@ -14,6 +14,7 @@ from frappe.utils.data import (
cint,
date_diff,
flt,
get_first_day,
get_last_day,
get_link_to_form,
getdate,
@@ -35,6 +36,24 @@ class InvoiceNotCancelled(frappe.ValidationError):
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
@@ -64,11 +83,13 @@ class Subscription(Document):
end_date: DF.Date | None
follow_calendar_months: DF.Check
generate_invoice_at: DF.Literal[
"End of the current subscription period",
"Beginning of the current subscription period",
"Days before the current subscription period",
"Postpaid (bill at period end)",
"Prepaid (bill at period start)",
"Bill N days before period start",
]
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
party: DF.DynamicLink
party_type: DF.Link
@@ -76,7 +97,9 @@ class Subscription(Document):
purchase_tax_template: DF.Link | None
sales_tax_template: DF.Link | 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
trial_period_end: 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`).
"""
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())
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
if not self.generate_new_invoices_past_due_date:
break
def _next_invoice_trigger_date(self) -> DateTimeLikeObject:
if self.generate_invoice_at == "Beginning of the current subscription period":
return self.current_invoice_start
if self.generate_invoice_at == "Days before the current subscription period":
return add_days(self.current_invoice_start, -self.number_of_days)
return self.current_invoice_end
return self._invoice_date_for_period(self.next_billing_period_start, self.next_billing_period_end)
def _invoice_date_for_period(
self, period_start: DateTimeLikeObject, period_end: DateTimeLikeObject
) -> 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):
"""
Subscription period is the period to be billed. This method updates the
beginning of the billing period and end of the billing period.
The beginning of the billing period is represented in the doctype as
`current_invoice_start` and the end of the billing period is represented
as `current_invoice_end`.
`next_billing_period_start` and the end of the billing period is represented
as `next_billing_period_end`.
"""
self.current_invoice_start = self.get_current_invoice_start(date)
self.current_invoice_end = self.get_current_invoice_end(self.current_invoice_start)
def _get_subscription_period(self, date: 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
self.next_billing_period_start = self.get_current_invoice_start(date)
self.next_billing_period_end = self.get_current_invoice_end(self.next_billing_period_start)
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)
# 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)
else:
_current_invoice_end = add_to_date(date, **billing_cycle_info)
@@ -253,21 +277,35 @@ class Subscription(Document):
"""
Sets the status of the `Subscription`
"""
self._set_current_invoice_dates()
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 (
not self.has_outstanding_invoice()
and self.end_date
and getdate(posting_date) > getdate(self.end_date)
):
self.status = "Completed"
self.status = STATUS_COMPLETED
elif self.is_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():
self.status = "Grace Period"
self.status = STATUS_GRACE_PERIOD
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:
"""
@@ -282,7 +320,6 @@ class Subscription(Document):
"""
Returns true if the given `end_date` has passed
"""
# todo: test for illegal time
if not end_date:
return True
@@ -290,10 +327,10 @@ class Subscription(Document):
def get_status_for_past_grace_period(self) -> str:
cancel_after_grace = cint(frappe.get_value("Subscription Settings", None, "cancel_after_grace"))
status = "Unpaid"
status = STATUS_UNPAID
if cancel_after_grace:
status = "Cancelled"
status = STATUS_CANCELLED
return status
@@ -321,7 +358,7 @@ class Subscription(Document):
@property
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:
self.validate_trial_period()
@@ -413,11 +450,7 @@ class Subscription(Document):
to_date: DateTimeLikeObject | None = None,
posting_date: DateTimeLikeObject | None = None,
) -> Document:
"""
Creates a `Invoice` for the `Subscription`, updates `self.invoices` and
saves the `Subscription`.
Backwards compatibility
"""
"""Public alias for `create_invoice`; kept for external integrations."""
return self.create_invoice(from_date=from_date, to_date=to_date, posting_date=posting_date)
def create_invoice(
@@ -429,8 +462,19 @@ class Subscription(Document):
"""
Creates a `Invoice`, submits it and returns it
"""
# For backward compatibility
# Earlier subscription didn't had any company field
company = self._resolve_company()
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()
if not company:
frappe.throw(
@@ -438,48 +482,49 @@ class Subscription(Document):
"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.company = company
invoice.set_posting_time = 1
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.posting_date = self._invoice_posting_date(posting_date)
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":
invoice.customer = self.party
else:
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
return
# 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")
# Add dimensions in invoice for subscription:
accounting_dimensions = get_accounting_dimensions()
for dimension in accounting_dimensions:
def _apply_accounting_dimensions(self, invoice: Document) -> None:
for dimension in get_accounting_dimensions():
if self.get(dimension):
invoice.update({dimension: self.get(dimension)})
# Subscription is better suited for service items. I won't update `update_stock`
# for that reason
items_list = self.get_items_from_plans(self.plans, is_prorate())
for item in items_list:
def _append_invoice_items(self, invoice: Document) -> None:
# Subscription is better suited for service items, so `update_stock` is left untouched
for item in self.get_items_from_plans(self.plans, is_prorate()):
invoice.append("items", item)
# Taxes
def _apply_taxes(self, invoice: Document) -> None:
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
TaxService(invoice).set_taxes()
# Due date
if self.days_until_due:
invoice.append(
"payment_schedule",
{
"due_date": add_days(invoice.posting_date, cint(self.days_until_due)),
"invoice_portion": 100,
},
)
def _apply_payment_schedule(self, invoice: Document) -> None:
if not self.days_until_due:
return
# 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():
invoice.additional_discount_percentage = 100
else:
if self.additional_discount_percentage:
invoice.additional_discount_percentage = self.additional_discount_percentage
return
if self.additional_discount_amount:
invoice.discount_amount = self.additional_discount_amount
if self.additional_discount_percentage:
invoice.additional_discount_percentage = self.additional_discount_percentage
if self.additional_discount_percentage or self.additional_discount_amount:
discount_on = self.apply_additional_discount
invoice.apply_discount_on = discount_on if discount_on else "Grand Total"
if self.additional_discount_amount:
invoice.discount_amount = self.additional_discount_amount
# 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.from_date = from_date or self.current_invoice_start
invoice.to_date = to_date or self.current_invoice_end
invoice.from_date = from_date or self.next_billing_period_start
invoice.to_date = to_date or self.next_billing_period_end
invoice.flags.ignore_mandatory = True
invoice.set_missing_values()
invoice.save()
@@ -540,15 +591,9 @@ class Subscription(Document):
prorate_factor = 1
if prorate:
prorate_factor = get_prorata_factor(
self.current_invoice_end,
self.current_invoice_start,
cint(
self.generate_invoice_at
in [
"Beginning of the current subscription period",
"Days before the current subscription period",
]
),
self.next_billing_period_end,
self.next_billing_period_start,
cint(self.generate_invoice_at in [GENERATE_AT_BEGINNING, GENERATE_AT_DAYS_BEFORE]),
)
items = []
@@ -558,7 +603,7 @@ class Subscription(Document):
item_code = plan_doc.item
if self.party_type == "Customer":
if self.party_type == PARTY_CUSTOMER:
deferred_field = "enable_deferred_revenue"
else:
deferred_field = "enable_deferred_expense"
@@ -572,8 +617,8 @@ class Subscription(Document):
plan.plan,
plan.qty,
party,
self.current_invoice_start,
self.current_invoice_end,
self.next_billing_period_start,
self.next_billing_period_end,
prorate_factor,
),
"cost_center": plan_doc.cost_center,
@@ -583,8 +628,8 @@ class Subscription(Document):
item.update(
{
deferred_field: deferred,
"service_start_date": self.current_invoice_start,
"service_end_date": self.current_invoice_end,
"service_start_date": self.next_billing_period_start,
"service_end_date": self.next_billing_period_end,
}
)
@@ -607,11 +652,11 @@ class Subscription(Document):
2. `process_for_past_due`
"""
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):
self.generate_invoice(posting_date=posting_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 self.cancel_at_period_end:
@@ -621,12 +666,12 @@ class Subscription(Document):
self.save()
return
self.update_subscription_period(add_days(self.current_invoice_end, 1))
elif posting_date and getdate(posting_date) > getdate(self.current_invoice_end):
self.update_subscription_period(add_days(self.next_billing_period_end, 1))
elif posting_date and getdate(posting_date) > getdate(self.next_billing_period_end):
self.update_subscription_period()
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)
):
self.cancel_subscription()
@@ -652,9 +697,9 @@ class Subscription(Document):
# multi-year gap doesn't retroactively bill cycle after cycle in one call.
billing_cycle_info = self.get_billing_cycle_data()
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:
upper = getdate(self.current_invoice_end)
upper = getdate(self.next_billing_period_end)
return posting <= upper
@@ -664,9 +709,8 @@ class Subscription(Document):
_current_end_date: DateTimeLikeObject | None = None,
) -> bool:
if not (_current_start_date and _current_end_date):
_current_start_date, _current_end_date = self._get_subscription_period(
date=add_days(self.current_invoice_end, 1)
)
_current_start_date = self.get_current_invoice_start(add_days(self.next_billing_period_end, 1))
_current_end_date = self.get_current_invoice_end(_current_start_date)
if self.current_invoice and getdate(_current_start_date) <= getdate(
self.current_invoice.posting_date
@@ -688,7 +732,7 @@ class Subscription(Document):
"""
invoice = frappe.get_all(
self.invoice_document_type,
{"subscription": self.name, "docstatus": ("<", 2)},
{"subscription": self.name, "docstatus": ("<", 2), "is_return": 0},
limit=1,
order_by="to_date desc",
pluck="name",
@@ -710,41 +754,70 @@ class Subscription(Document):
"""
Return `True` if the given invoice is paid
"""
return invoice.status == "Paid"
return invoice.status == INVOICE_PAID
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(
self.invoice_document_type,
{
"subscription": self.name,
"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()
def cancel_subscription(self) -> None:
"""
This sets the subscription as cancelled. It will stop invoices from being generated
but it will not affect already created invoices.
"""
if self.status == "Cancelled":
if self.status == STATUS_CANCELLED:
frappe.throw(_("subscription is already cancelled."), InvoiceCancelled)
to_generate_invoice = (
True
if self.status == "Active"
and self.generate_invoice_at != "Beginning of the current subscription period"
if self.status == STATUS_ACTIVE and self.generate_invoice_at != GENERATE_AT_BEGINNING
else False
)
self.status = "Cancelled"
self.status = STATUS_CANCELLED
self.cancelation_date = nowdate()
if to_generate_invoice and getdate(self.cancelation_date) >= getdate(self.current_invoice_start):
self.generate_invoice(self.current_invoice_start, self.cancelation_date)
if to_generate_invoice and getdate(self.cancelation_date) >= getdate(self.next_billing_period_start):
self.generate_invoice(self.next_billing_period_start, self.cancelation_date)
self.save()
@@ -755,10 +828,10 @@ class Subscription(Document):
subscription and the `Subscription` will lose all the history of generated invoices
it has.
"""
if self.status != "Cancelled":
if self.status != STATUS_CANCELLED:
frappe.throw(_("You cannot restart a Subscription that is not cancelled."), InvoiceNotCancelled)
self.status = "Active"
self.status = STATUS_ACTIVE
self.cancelation_date = None
self.update_subscription_period(posting_date or nowdate())
self.save()
@@ -766,25 +839,130 @@ class Subscription(Document):
@frappe.whitelist()
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'
"""
# 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."))
return
processing_date = None
if self.generate_invoice_at == "Beginning of the current subscription period":
processing_date = self.current_invoice_start
elif self.generate_invoice_at == "End of the current subscription period":
processing_date = self.current_invoice_end
elif self.generate_invoice_at == "Days before the current subscription period":
processing_date = add_days(self.current_invoice_start, -self.number_of_days)
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
processing_date = self.next_billing_period_start
elif self.generate_invoice_at == GENERATE_AT_END:
processing_date = self.next_billing_period_end
elif self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
processing_date = add_days(self.next_billing_period_start, -self.number_of_days)
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:
return cint(frappe.db.get_single_value("Subscription Settings", "prorate"))

View File

@@ -11,6 +11,8 @@ from frappe.utils.data import (
date_diff,
flt,
get_date_str,
get_first_day,
get_last_day,
getdate,
nowdate,
)
@@ -35,11 +37,11 @@ class TestSubscription(ERPNextTestSuite):
self.assertEqual(subscription.trial_period_start, nowdate())
self.assertEqual(subscription.trial_period_end, add_months(nowdate(), 1))
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(
add_to_date(subscription.current_invoice_start, months=1, days=-1),
get_date_str(subscription.current_invoice_end),
add_to_date(subscription.next_billing_period_start, months=1, days=-1),
get_date_str(subscription.next_billing_period_end),
)
self.assertEqual(subscription.invoices, [])
self.assertEqual(subscription.status, "Trialing")
@@ -48,8 +50,8 @@ class TestSubscription(ERPNextTestSuite):
subscription = create_subscription()
self.assertEqual(subscription.trial_period_start, None)
self.assertEqual(subscription.trial_period_end, None)
self.assertEqual(subscription.current_invoice_start, nowdate())
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(subscription.next_billing_period_start, nowdate())
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
# No invoice is created
self.assertEqual(len(subscription.invoices), 0)
self.assertEqual(subscription.status, "Active")
@@ -66,12 +68,12 @@ class TestSubscription(ERPNextTestSuite):
subscription = create_subscription(start_date="2018-01-01")
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, "Unpaid")
self.assertEqual(getdate(subscription.current_invoice_start), getdate("2018-02-01"))
self.assertEqual(getdate(subscription.current_invoice_end), getdate("2018-02-28"))
self.assertEqual(getdate(subscription.next_billing_period_start), getdate("2018-02-01"))
self.assertEqual(getdate(subscription.next_billing_period_end), getdate("2018-02-28"))
def test_status_goes_back_to_active_after_invoice_is_paid(self):
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
self.assertEqual(len(subscription.invoices), 1)
@@ -89,7 +91,7 @@ class TestSubscription(ERPNextTestSuite):
subscription.process()
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)
def test_subscription_cancel_after_grace_period(self):
@@ -122,7 +124,7 @@ class TestSubscription(ERPNextTestSuite):
_date = add_months(nowdate(), -1)
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(subscription.status, "Active")
@@ -134,7 +136,7 @@ class TestSubscription(ERPNextTestSuite):
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")
subscription.process()
@@ -154,20 +156,20 @@ class TestSubscription(ERPNextTestSuite):
subscription = create_subscription() # no changes expected
self.assertEqual(subscription.status, "Active")
self.assertEqual(subscription.current_invoice_start, nowdate())
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(subscription.next_billing_period_start, nowdate())
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(len(subscription.invoices), 0)
subscription.process() # no changes expected still
self.assertEqual(subscription.status, "Active")
self.assertEqual(subscription.current_invoice_start, nowdate())
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(subscription.next_billing_period_start, nowdate())
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(len(subscription.invoices), 0)
subscription.process() # no changes expected yet still
self.assertEqual(subscription.status, "Active")
self.assertEqual(subscription.current_invoice_start, nowdate())
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(subscription.next_billing_period_start, nowdate())
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(len(subscription.invoices), 0)
def test_subscription_cancellation(self):
@@ -191,16 +193,18 @@ class TestSubscription(ERPNextTestSuite):
self.assertEqual(len(subscription.invoices), 1)
invoice = subscription.get_current_invoice()
diff = flt(date_diff(nowdate(), subscription.current_invoice_start) + 1)
plan_days = flt(date_diff(subscription.current_invoice_end, subscription.current_invoice_start) + 1)
diff = flt(date_diff(nowdate(), subscription.next_billing_period_start) + 1)
plan_days = flt(
date_diff(subscription.next_billing_period_end, subscription.next_billing_period_start) + 1
)
prorate_factor = flt(diff / plan_days)
self.assertEqual(
flt(
get_prorata_factor(
subscription.current_invoice_end,
subscription.current_invoice_start,
cint(subscription.generate_invoice_at == "Beginning of the current subscription period"),
subscription.next_billing_period_end,
subscription.next_billing_period_start,
cint(subscription.generate_invoice_at == "Prepaid (bill at period start)"),
),
2,
),
@@ -237,8 +241,10 @@ class TestSubscription(ERPNextTestSuite):
subscription.cancel_subscription()
invoice = subscription.get_current_invoice()
diff = flt(date_diff(nowdate(), subscription.current_invoice_start) + 1)
plan_days = flt(date_diff(subscription.current_invoice_end, subscription.current_invoice_start) + 1)
diff = flt(date_diff(nowdate(), subscription.next_billing_period_start) + 1)
plan_days = flt(
date_diff(subscription.next_billing_period_end, subscription.next_billing_period_start) + 1
)
prorate_factor = flt(diff / plan_days)
self.assertEqual(flt(invoice.grand_total, 2), flt(prorate_factor * 900, 2))
@@ -303,9 +309,9 @@ class TestSubscription(ERPNextTestSuite):
settings.save()
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
self.assertEqual(subscription.status, "Unpaid")
@@ -317,7 +323,7 @@ class TestSubscription(ERPNextTestSuite):
self.assertEqual(subscription.status, "Active")
# 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")
settings.cancel_after_grace = default_grace_period_action
@@ -354,7 +360,7 @@ class TestSubscription(ERPNextTestSuite):
# Change the subscription type to prebilled and process it.
# 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.process()
@@ -366,7 +372,7 @@ class TestSubscription(ERPNextTestSuite):
settings.prorate = 1
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.cancel_subscription()
@@ -387,7 +393,7 @@ class TestSubscription(ERPNextTestSuite):
subscription.company = "_Test Company"
subscription.party_type = "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
# select subscription start date as "2018-01-15"
@@ -413,7 +419,7 @@ class TestSubscription(ERPNextTestSuite):
end_date="2018-12-31",
party_type="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,
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
)
@@ -424,7 +430,7 @@ class TestSubscription(ERPNextTestSuite):
def test_subscription_without_generate_invoice_past_due(self):
subscription = create_subscription(
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}],
)
@@ -442,7 +448,7 @@ class TestSubscription(ERPNextTestSuite):
frappe.db.set_value("Customer", party, "default_currency", "USD")
subscription = create_subscription(
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"}],
party=party,
)
@@ -464,7 +470,7 @@ class TestSubscription(ERPNextTestSuite):
frappe.db.set_value("Customer", party, "default_currency", "USD")
subscription = create_subscription(
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"}],
party=party,
)
@@ -517,7 +523,7 @@ class TestSubscription(ERPNextTestSuite):
subscription = create_subscription(
start_date="2023-01-01",
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,
generate_new_invoices_past_due_date=1,
)
@@ -555,7 +561,7 @@ class TestSubscription(ERPNextTestSuite):
start_date=start_date,
party_type="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,
number_of_days=2,
plans=[{"plan": "_Test Plan Name 5", "qty": 1}],
@@ -577,7 +583,7 @@ class TestSubscription(ERPNextTestSuite):
end_date=add_days(start_date, 8),
cancel_at_period_end=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}],
)
# 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),
cancel_at_period_end=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}],
)
@@ -684,7 +690,7 @@ class TestSubscription(ERPNextTestSuite):
end_date=end_date,
party_type="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,
plans=[{"plan": "_Test Plan 3 Day", "qty": 1}],
)
@@ -713,7 +719,7 @@ class TestSubscription(ERPNextTestSuite):
def test_status_updates_immediately_when_invoice_paid(self):
subscription = create_subscription(
start_date=nowdate(),
generate_invoice_at="Beginning of the current subscription period",
generate_invoice_at="Prepaid (bill at period start)",
submit_invoice=1,
)
subscription.process(posting_date=nowdate())
@@ -729,7 +735,7 @@ class TestSubscription(ERPNextTestSuite):
def test_invoice_update_hook_refreshes_subscription_status(self):
subscription = create_subscription(
start_date=nowdate(),
generate_invoice_at="Beginning of the current subscription period",
generate_invoice_at="Prepaid (bill at period start)",
submit_invoice=1,
)
subscription.process(posting_date=nowdate())
@@ -748,7 +754,7 @@ class TestSubscription(ERPNextTestSuite):
# Test that payment entry → invoice → subscription status update chain works
subscription = create_subscription(
start_date=nowdate(),
generate_invoice_at="Beginning of the current subscription period",
generate_invoice_at="Prepaid (bill at period start)",
submit_invoice=1,
)
subscription.process(posting_date=nowdate())
@@ -771,16 +777,33 @@ class TestSubscription(ERPNextTestSuite):
def test_first_invoice_generated_on_create_for_prepaid(self):
subscription = create_subscription(
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)
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):
subscription = create_subscription(
start_date=nowdate(),
trial_period_start=nowdate(),
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(subscription.status, "Trialing")
@@ -790,7 +813,7 @@ class TestSubscription(ERPNextTestSuite):
try:
subscription = create_subscription(
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)
finally:
@@ -799,10 +822,144 @@ class TestSubscription(ERPNextTestSuite):
def test_first_invoice_not_generated_for_future_dated_subscription(self):
subscription = create_subscription(
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)
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():
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.rename_secondary_item_type_field
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()