diff --git a/erpnext/accounts/doctype/subscription/subscription.js b/erpnext/accounts/doctype/subscription/subscription.js
index 07da50ce89a..71d3929e9cc 100644
--- a/erpnext/accounts/doctype/subscription/subscription.js
+++ b/erpnext/accounts/doctype/subscription/subscription.js
@@ -96,8 +96,6 @@ frappe.ui.form.on("Subscription", {
});
},
});
-<<<<<<< HEAD
-=======
frappe.ui.form.on("Subscription Plan Detail", {
plan: function (frm, cdt, cdn) {
@@ -124,81 +122,3 @@ frappe.ui.form.on("Subscription Plan Detail", {
});
},
});
-
-// 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
- );
-}
->>>>>>> 7febc28ed6 (feat: auto-fill subscription accounting dimensions from plan with item fallback (#57615))
diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py
index 2d8fbb73033..3665bf34bf2 100644
--- a/erpnext/accounts/doctype/subscription/subscription.py
+++ b/erpnext/accounts/doctype/subscription/subscription.py
@@ -25,11 +25,8 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
-<<<<<<< HEAD
from erpnext.accounts.party import get_party_account_currency
-=======
from erpnext.stock.doctype.item.item import get_item_defaults
->>>>>>> 7febc28ed6 (feat: auto-fill subscription accounting dimensions from plan with item fallback (#57615))
class InvoiceCancelled(frappe.ValidationError):
@@ -779,7 +776,7 @@ def get_item_dimension(
selling = item_defaults.get("selling_cost_center")
buying = item_defaults.get("buying_cost_center")
- if party_type == PARTY_SUPPLIER:
+ if party_type == "Supplier":
return buying or selling
return selling or buying
diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py
index 0d162dcc31e..4de6aca2594 100644
--- a/erpnext/accounts/doctype/subscription/test_subscription.py
+++ b/erpnext/accounts/doctype/subscription/test_subscription.py
@@ -17,21 +17,9 @@ from frappe.utils.data import (
nowdate,
)
-<<<<<<< HEAD
-from erpnext.accounts.doctype.subscription.subscription import get_prorata_factor
+from erpnext.accounts.doctype.subscription.subscription import get_plan_dimensions, get_prorata_factor
test_dependencies = ("UOM", "Item Group", "Item")
-=======
-from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
-from erpnext.accounts.doctype.subscription.subscription import (
- Subscription,
- get_plan_dimensions,
- get_prorata_factor,
- process_all,
-)
-from erpnext.accounts.utils import update_subscription_on_invoice_update
-from erpnext.tests.utils import ERPNextTestSuite
->>>>>>> 7febc28ed6 (feat: auto-fill subscription accounting dimensions from plan with item fallback (#57615))
class TestSubscription(FrappeTestCase):
@@ -595,390 +583,6 @@ class TestSubscription(FrappeTestCase):
subscription.process(nowdate())
self.assertEqual(len(subscription.invoices), 1)
-<<<<<<< HEAD
-=======
- def test_subscription_auto_cancellation(self):
- create_plan(
- plan_name="_Test plan name 10",
- cost=80,
- currency="INR",
- billing_interval="Day",
- billing_interval_count=3,
- )
- start_date = getdate("2025-01-01")
- subscription = create_subscription(
- start_date=start_date,
- end_date=add_days(start_date, 8),
- cancel_at_period_end=1,
- generate_new_invoices_past_due_date=1,
- 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
- self.assertEqual(len(subscription.invoices), 3)
- self.assertEqual(subscription.status, "Cancelled")
-
- def test_subscription_auto_cancellation_uneven_cycle(self):
- create_plan(
- plan_name="_Test plan name 10",
- cost=80,
- currency="INR",
- billing_interval="Day",
- billing_interval_count=3,
- )
- start_date = getdate("2025-01-01")
- subscription = create_subscription(
- start_date=start_date,
- end_date=add_days(start_date, 6),
- cancel_at_period_end=1,
- generate_new_invoices_past_due_date=1,
- generate_invoice_at="Prepaid (bill at period start)",
- plans=[{"plan": "_Test plan name 10", "qty": 1}],
- )
-
- # Catch-up billing on creation incl. the partial last cycle, then cancels at end
- self.assertEqual(len(subscription.invoices), 3)
- self.assertEqual(subscription.status, "Cancelled")
-
- self.assertRaises(frappe.ValidationError, subscription.process, posting_date=add_days(start_date, 7))
-
- def test_invoice_generated_when_scheduler_runs_one_day_late(self):
- # The trigger date (period end) is long past, yet catch-up still bills the period
- # on creation (Bug 1: the check is `>= trigger`, not `== trigger`).
- subscription = create_subscription(start_date="2018-01-01")
- self.assertEqual(len(subscription.invoices), 1)
-
- def test_deferred_revenue_applied_for_customer_subscription(self):
- item_code = "_Test Non Stock Item"
- frappe.db.set_value("Item", item_code, "enable_deferred_revenue", 1)
- try:
- # Build the period without saving, so on-create billing doesn't try to post an
- # invoice (the deferred item has no account configured). This only exercises the
- # item-mapping helper.
- subscription = create_subscription(start_date="2018-01-01", do_not_save=True)
- subscription.update_subscription_period("2018-01-01")
- items = subscription.get_items_from_plans(subscription.plans)
- self.assertEqual(items[0].get("enable_deferred_revenue"), 1)
- self.assertEqual(getdate(items[0]["service_start_date"]), getdate("2018-01-01"))
- self.assertEqual(getdate(items[0]["service_end_date"]), getdate("2018-01-31"))
- finally:
- frappe.db.set_value("Item", item_code, "enable_deferred_revenue", 0)
-
- def test_validate_end_date_with_no_plans_does_not_crash(self):
- sub = frappe.new_doc("Subscription")
- sub.party_type = "Customer"
- sub.party = "_Test Customer"
- sub.company = "_Test Company"
- sub.start_date = "2018-01-01"
- sub.end_date = "2018-03-01"
- try:
- sub.validate_end_date()
- except TypeError as e:
- self.fail(f"validate_end_date crashed with no plans: {e}")
-
- def test_process_all_logs_error_when_first_subscription_fails(self):
- sub1 = create_subscription(start_date="2018-01-01")
- sub2 = create_subscription(start_date="2018-01-02")
-
- processed = []
- original_process = Subscription.process
- original_rollback = frappe.db.rollback
-
- def patched(self, posting_date=None):
- processed.append(self.name)
- if self.name == sub1.name:
- raise frappe.ValidationError("forced failure")
-
- Subscription.process = patched
- # process_all calls frappe.db.rollback() on error which would otherwise wipe
- # the test transaction; stub it so we can observe the iteration in isolation.
- frappe.db.rollback = lambda *a, **kw: None
- try:
- process_all([sub1.name, sub2.name])
- finally:
- Subscription.process = original_process
- frappe.db.rollback = original_rollback
-
- self.assertEqual(processed, [sub1.name, sub2.name])
-
- def test_subscription_auto_completion(self):
- create_plan(
- plan_name="_Test Plan 3 Day",
- cost=100,
- billing_interval="Day",
- billing_interval_count=3,
- currency="INR",
- )
-
- start_date = getdate("2025-01-01")
- end_date = add_days(start_date, 6)
-
- subscription = create_subscription(
- start_date=start_date,
- end_date=end_date,
- party_type="Customer",
- party="_Test Customer",
- generate_invoice_at="Prepaid (bill at period start)",
- generate_new_invoices_past_due_date=1,
- plans=[{"plan": "_Test Plan 3 Day", "qty": 1}],
- )
-
- for day in range(0, 10):
- if subscription.status == "Cancelled":
- break
- subscription.process(posting_date=add_days(start_date, day))
-
- invoices = frappe.get_all(
- "Sales Invoice",
- filters={"subscription": subscription.name, "docstatus": 1},
- fields=["name", "from_date", "to_date"],
- order_by="from_date asc",
- )
- for invoice in invoices:
- pi = get_payment_entry("Sales Invoice", invoice.name)
- pi.submit()
- # Paying the invoices refreshes the subscription via the Payment Entry hook, so
- # reload before processing the stale in-memory copy.
- subscription.reload()
- # After processing through all days, subscription should be completed
- subscription.process(posting_date=add_days(end_date, 1))
- self.assertEqual(subscription.status, "Completed")
-
- def test_status_updates_immediately_when_invoice_paid(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")
-
- invoice = subscription.get_current_invoice()
- payment = get_payment_entry("Sales Invoice", invoice.name)
- payment.submit()
-
- subscription.reload()
- self.assertEqual(subscription.status, "Active")
-
- def test_invoice_update_hook_refreshes_subscription_status(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")
-
- invoice = subscription.get_current_invoice()
- invoice.db_set("outstanding_amount", 0)
- invoice.db_set("status", "Paid")
-
- update_subscription_on_invoice_update(invoice)
-
- subscription.reload()
- self.assertEqual(subscription.status, "Active")
-
- def test_payment_entry_triggers_subscription_status_update(self):
- # Test that payment entry → invoice → subscription status update chain works
- 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")
-
- invoice = subscription.get_current_invoice()
- self.assertIsNotNone(invoice)
- self.assertGreater(invoice.outstanding_amount, 0)
-
- # Create and submit payment entry
- payment_entry = get_payment_entry(invoice.doctype, invoice.name, bank_account="_Test Bank - _TC")
- payment_entry.reference_no = "12345"
- payment_entry.reference_date = nowdate()
- payment_entry.submit()
-
- # Subscription status should now be Active (via on_update_after_submit hook)
- subscription.reload()
- self.assertEqual(subscription.status, "Active")
-
- def test_first_invoice_generated_on_create_for_prepaid(self):
- subscription = create_subscription(
- start_date=nowdate(),
- 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="Prepaid (bill at period start)",
- )
- self.assertEqual(len(subscription.invoices), 0)
- self.assertEqual(subscription.status, "Trialing")
-
- def test_first_invoice_not_generated_during_bulk_import(self):
- frappe.flags.in_import = True
- try:
- subscription = create_subscription(
- start_date=nowdate(),
- generate_invoice_at="Prepaid (bill at period start)",
- )
- self.assertEqual(len(subscription.invoices), 0)
- finally:
- frappe.flags.in_import = False
-
- def test_first_invoice_not_generated_for_future_dated_subscription(self):
- subscription = create_subscription(
- start_date=add_days(nowdate(), 10),
- 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 test_plan_dimensions_resolve_from_plan_then_item(self):
from erpnext.stock.doctype.item.test_item import make_item
@@ -1021,16 +625,6 @@ class TestSubscription(FrappeTestCase):
self.assertNotIn("cost_center", get_plan_dimensions("_Test Sub Plan No CC"))
-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
-
->>>>>>> 7febc28ed6 (feat: auto-fill subscription accounting dimensions from plan with item fallback (#57615))
-
def make_plans():
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")
create_plan(plan_name="_Test Plan Name 2", cost=1999, currency="INR")