From 4c3aa9b4f31d6587a044b6914b2c56a8ef9331e4 Mon Sep 17 00:00:00 2001 From: Jatin3128 <140256508+Jatin3128@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:43:24 +0530 Subject: [PATCH] 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 --- .../purchase_invoice/purchase_invoice.py | 5 + .../doctype/sales_invoice/sales_invoice.py | 4 + .../doctype/subscription/subscription.js | 92 +++- .../doctype/subscription/subscription.json | 71 ++- .../doctype/subscription/subscription.py | 438 ++++++++++++------ .../doctype/subscription/test_subscription.py | 249 ++++++++-- erpnext/patches.txt | 2 + ...igrate_subscription_generate_invoice_at.py | 17 + ...name_subscription_billing_period_fields.py | 26 ++ 9 files changed, 714 insertions(+), 190 deletions(-) create mode 100644 erpnext/patches/v16_0/migrate_subscription_generate_invoice_at.py create mode 100644 erpnext/patches/v16_0/rename_subscription_billing_period_fields.py diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 0fb91a854fd..0ce3b42c56e 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -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: diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 2fd3cc84db3..29725897883 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -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): diff --git a/erpnext/accounts/doctype/subscription/subscription.js b/erpnext/accounts/doctype/subscription/subscription.js index 629d118080a..9e12afcddd0 100644 --- a/erpnext/accounts/doctype/subscription/subscription.js +++ b/erpnext/accounts/doctype/subscription/subscription.js @@ -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 = $('
').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) => + ` + + ${__(status)} + ` + ) + .join(""); + + $(`
${legend}
`).appendTo( + $wrapper + ); +} diff --git a/erpnext/accounts/doctype/subscription/subscription.json b/erpnext/accounts/doctype/subscription/subscription.json index 77885cacac9..4dc11499317 100644 --- a/erpnext/accounts/doctype/subscription/subscription.json +++ b/erpnext/accounts/doctype/subscription/subscription.json @@ -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": [ { diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py index d8e6ac5d30d..21cd276c508 100644 --- a/erpnext/accounts/doctype/subscription/subscription.py +++ b/erpnext/accounts/doctype/subscription/subscription.py @@ -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")) diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py index fc7ebc4c7b5..02e8fec22b6 100644 --- a/erpnext/accounts/doctype/subscription/test_subscription.py +++ b/erpnext/accounts/doctype/subscription/test_subscription.py @@ -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") diff --git a/erpnext/patches.txt b/erpnext/patches.txt index f330b0b38e3..e3491ab2ed7 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -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 diff --git a/erpnext/patches/v16_0/migrate_subscription_generate_invoice_at.py b/erpnext/patches/v16_0/migrate_subscription_generate_invoice_at.py new file mode 100644 index 00000000000..d264bab36db --- /dev/null +++ b/erpnext/patches/v16_0/migrate_subscription_generate_invoice_at.py @@ -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() diff --git a/erpnext/patches/v16_0/rename_subscription_billing_period_fields.py b/erpnext/patches/v16_0/rename_subscription_billing_period_fields.py new file mode 100644 index 00000000000..bb8b8a96ed7 --- /dev/null +++ b/erpnext/patches/v16_0/rename_subscription_billing_period_fields.py @@ -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()