diff --git a/erpnext/accounts/doctype/subscription/subscription.js b/erpnext/accounts/doctype/subscription/subscription.js index 629d118080a..71d3929e9cc 100644 --- a/erpnext/accounts/doctype/subscription/subscription.js +++ b/erpnext/accounts/doctype/subscription/subscription.js @@ -96,3 +96,29 @@ frappe.ui.form.on("Subscription", { }); }, }); + +frappe.ui.form.on("Subscription Plan Detail", { + plan: function (frm, cdt, cdn) { + const row = locals[cdt][cdn]; + if (!row.plan) return; + const requested_plan = row.plan; + + frappe.call({ + method: "erpnext.accounts.doctype.subscription.subscription.get_plan_dimensions", + args: { + plan: requested_plan, + company: frm.doc.company, + party_type: frm.doc.party_type, + }, + callback: function (r) { + if (!r.message || locals[cdt]?.[cdn]?.plan !== requested_plan) return; + // Only fill dimensions left empty, so a manual entry or an earlier plan is never overwritten. + for (const [dimension, value] of Object.entries(r.message)) { + if (frm.fields_dict[dimension] && !frm.doc[dimension]) { + frm.set_value(dimension, value); + } + } + }, + }); + }, +}); diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py index 570cfd847f9..8cf237ba475 100644 --- a/erpnext/accounts/doctype/subscription/subscription.py +++ b/erpnext/accounts/doctype/subscription/subscription.py @@ -25,6 +25,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, ) from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate +from erpnext.stock.doctype.item.item import get_item_defaults class InvoiceCancelled(frappe.ValidationError): @@ -801,6 +802,39 @@ def get_prorata_factor( return diff / plan_days +@frappe.whitelist() +def get_plan_dimensions( + plan: str, company: str | None = None, party_type: str | None = None +) -> dict[str, str]: + """Resolve a plan's accounting dimensions, falling back to the plan item's company defaults.""" + plan_doc = frappe.get_cached_doc("Subscription Plan", plan) + + dimensions = {} + for dimension in ["cost_center", *get_accounting_dimensions()]: + value = plan_doc.get(dimension) or get_item_dimension(plan_doc.item, dimension, company, party_type) + if value: + dimensions[dimension] = value + + return dimensions + + +def get_item_dimension( + item_code: str, dimension: str, company: str | None, party_type: str | None +) -> str | None: + if not company: + return None + + item_defaults = get_item_defaults(item_code, company) + if dimension != "cost_center": + return item_defaults.get(dimension) + + selling = item_defaults.get("selling_cost_center") + buying = item_defaults.get("buying_cost_center") + if party_type == "Supplier": + return buying or selling + return selling or buying + + def process_all(subscription: list, posting_date: DateTimeLikeObject | None = None) -> None: """ Task to updates the status of all `Subscription` apart from those that are cancelled diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py index d86aa33bb9d..7df77a839be 100644 --- a/erpnext/accounts/doctype/subscription/test_subscription.py +++ b/erpnext/accounts/doctype/subscription/test_subscription.py @@ -17,7 +17,12 @@ from frappe.utils.data import ( ) from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry -from erpnext.accounts.doctype.subscription.subscription import Subscription, get_prorata_factor, process_all +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 @@ -804,6 +809,48 @@ class TestSubscription(ERPNextTestSuite): ) self.assertEqual(len(subscription.invoices), 0) + def test_plan_dimensions_resolve_from_plan_then_item(self): + from erpnext.stock.doctype.item.test_item import make_item + + # Plan-level cost center takes precedence. + create_plan(plan_name="_Test Sub Plan CC", cost=100, currency="INR") + frappe.db.set_value( + "Subscription Plan", "_Test Sub Plan CC", "cost_center", "_Test Cost Center - _TC" + ) + self.assertEqual( + get_plan_dimensions("_Test Sub Plan CC", "_Test Company", "Customer").get("cost_center"), + "_Test Cost Center - _TC", + ) + + # No plan cost center: fall back to the item's company default (selling vs buying by party type). + item = make_item( + "_Test Sub Dimension Item", + { + "is_stock_item": 0, + "item_defaults": [ + { + "company": "_Test Company", + "default_warehouse": "_Test Warehouse - _TC", + "selling_cost_center": "_Test Cost Center - _TC", + "buying_cost_center": "_Test Cost Center 2 - _TC", + } + ], + }, + ) + create_plan(plan_name="_Test Sub Plan No CC", cost=100, currency="INR", item=item.name) + + self.assertEqual( + get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Customer").get("cost_center"), + "_Test Cost Center - _TC", + ) + self.assertEqual( + get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Supplier").get("cost_center"), + "_Test Cost Center 2 - _TC", + ) + + # Without a company the item fallback is skipped. + self.assertNotIn("cost_center", get_plan_dimensions("_Test Sub Plan No CC")) + def make_plans(): create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")