From 39d96bdc52e08f4d924703c70c62194fd9d99523 Mon Sep 17 00:00:00 2001 From: Jatin3128 Date: Thu, 30 Jul 2026 15:35:45 +0530 Subject: [PATCH] fix: resolve backport merge conflicts for #57615 --- .../doctype/subscription/subscription.js | 80 ---- .../doctype/subscription/subscription.py | 5 +- .../doctype/subscription/test_subscription.py | 408 +----------------- 3 files changed, 2 insertions(+), 491 deletions(-) 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")