mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-12 06:01:46 +00:00
feat(subscription): add refunded status, billing heatmap and billing UX (#55617)
* fix(subscription): bill on creation and keep status in sync with invoices * feat(subscription): add refunded status, billing heatmap and billing UX
This commit is contained in:
@@ -653,6 +653,9 @@ class PurchaseInvoice(BuyingController):
|
||||
|
||||
self.process_common_party_accounting()
|
||||
|
||||
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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import frappe
|
||||
|
||||
VALUE_MAP = {
|
||||
"End of the current subscription period": "Postpaid (bill at period end)",
|
||||
"Beginning of the current subscription period": "Prepaid (bill at period start)",
|
||||
"Days before the current subscription period": "Bill N days before period start",
|
||||
}
|
||||
|
||||
|
||||
def execute():
|
||||
subscription = frappe.qb.DocType("Subscription")
|
||||
for old_value, new_value in VALUE_MAP.items():
|
||||
(
|
||||
frappe.qb.update(subscription)
|
||||
.set(subscription.generate_invoice_at, new_value)
|
||||
.where(subscription.generate_invoice_at == old_value)
|
||||
).run()
|
||||
@@ -0,0 +1,26 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
"""Move billing-period data to the renamed fields.
|
||||
|
||||
`current_invoice_start/end` used to hold the open (next) billing period and now
|
||||
holds the actual current invoice period, while the open period moved to
|
||||
`next_billing_period_start/end`.
|
||||
"""
|
||||
columns = set(frappe.db.get_table_columns("Subscription"))
|
||||
subscription = frappe.qb.DocType("Subscription")
|
||||
|
||||
if {"next_billing_period_start", "next_billing_period_end"} <= columns:
|
||||
(
|
||||
frappe.qb.update(subscription)
|
||||
.set(subscription.next_billing_period_start, subscription.current_invoice_start)
|
||||
.set(subscription.next_billing_period_end, subscription.current_invoice_end)
|
||||
).run()
|
||||
|
||||
if {"current_invoice_from_date", "current_invoice_to_date"} <= columns:
|
||||
(
|
||||
frappe.qb.update(subscription)
|
||||
.set(subscription.current_invoice_start, subscription.current_invoice_from_date)
|
||||
.set(subscription.current_invoice_end, subscription.current_invoice_to_date)
|
||||
).run()
|
||||
Reference in New Issue
Block a user