From 64e69d99f129fadfeb6877de4b187312561e5f5f Mon Sep 17 00:00:00 2001 From: alvaro-domatix Date: Wed, 10 Jun 2026 10:38:13 +0000 Subject: [PATCH] [ADD] subscription_oca: subscription analysis reporting Add a Reporting menu to analyse subscriptions with pivot and graph views, in the spirit of the Enterprise subscription reports but based only on community views: - New sale.subscription.report SQL view with one row per subscription line, exposing the customer, template, product, category, stage, close reason, salesperson, team, dates and the monthly/annual recurring revenue in company currency. - Three actions under Subscriptions > Reporting: Subscriptions Analysis, MRR Breakdown (running subscriptions by template and product) and Churn Analysis (closed subscriptions and lost revenue by close reason). - Read access for salesmen and a multi-company record rule on the report model. --- subscription_oca/README.rst | 59 ++-- subscription_oca/__init__.py | 1 + subscription_oca/__manifest__.py | 2 + subscription_oca/readme/USAGE.md | 13 + subscription_oca/report/__init__.py | 2 + .../report/sale_subscription_report.py | 121 +++++++ .../report/sale_subscription_report_views.xml | 311 ++++++++++++++++++ subscription_oca/security/ir.model.access.csv | 1 + .../sale_subscription_report_security.xml | 10 + .../static/description/index.html | 15 + subscription_oca/tests/__init__.py | 1 + .../tests/test_subscription_report.py | 98 ++++++ 12 files changed, 612 insertions(+), 22 deletions(-) create mode 100644 subscription_oca/report/__init__.py create mode 100644 subscription_oca/report/sale_subscription_report.py create mode 100644 subscription_oca/report/sale_subscription_report_views.xml create mode 100644 subscription_oca/security/sale_subscription_report_security.xml create mode 100644 subscription_oca/tests/test_subscription_report.py diff --git a/subscription_oca/README.rst b/subscription_oca/README.rst index 3bed5854f2..97d32b3d4e 100644 --- a/subscription_oca/README.rst +++ b/subscription_oca/README.rst @@ -74,21 +74,36 @@ To create subscriptions with the sale of a product: Invoice and delivery addresses: -- Each subscription has an *Invoice address* and a *Delivery address*. - They default to the customer's corresponding addresses and can be - overridden per subscription. -- These addresses are propagated to the recurring invoices (the invoice - is addressed to the invoice address and records the delivery one) and - to the sale orders generated by the subscription. -- The fields are shown when the *Customer Addresses* setting (group - *Display Delivery / Invoice addresses*) is enabled. +- Each subscription has an *Invoice address* and a *Delivery address*. + They default to the customer's corresponding addresses and can be + overridden per subscription. +- These addresses are propagated to the recurring invoices (the invoice + is addressed to the invoice address and records the delivery one) and + to the sale orders generated by the subscription. +- The fields are shown when the *Customer Addresses* setting (group + *Display Delivery / Invoice addresses*) is enabled. + +Reporting: + +- Go to *Subscriptions > Reporting* to analyse your recurring revenue + with pivot and graph views: + + - *Subscriptions Analysis*: recurring revenue per line, groupable by + customer, template, product, salesperson or start month. + - *MRR Breakdown*: monthly recurring revenue of the running + subscriptions by template and product. + - *Churn Analysis*: closed subscriptions and the revenue lost, + grouped by close reason. + +- All amounts are expressed in the company currency, so figures remain + comparable when subscriptions use pricelists in other currencies. Known issues / Roadmap ====================== -- Refactor all the onchanges that have business logic to computed - write-able fields when possible. Keep onchanges only for UI purposes. -- Add tests. +- Refactor all the onchanges that have business logic to computed + write-able fields when possible. Keep onchanges only for UI purposes. +- Add tests. Bug Tracker =========== @@ -112,22 +127,22 @@ Authors Contributors ------------ -- Carlos Martínez -- Carolina Ferrer -- `Ooops404 `__: +- Carlos Martínez +- Carolina Ferrer +- `Ooops404 `__: - - Ilyas + - Ilyas -- `Sygel `__: +- `Sygel `__: - - Harald Panten - - Valentin Vinagre - - Alberto Martínez + - Harald Panten + - Valentin Vinagre + - Alberto Martínez -- Dennis Sluijk -- `IKU Solutions `__: +- Dennis Sluijk +- `IKU Solutions `__: - - Yan Chirino + - Yan Chirino Maintainers ----------- diff --git a/subscription_oca/__init__.py b/subscription_oca/__init__.py index 9b4296142f..7660e7bf61 100644 --- a/subscription_oca/__init__.py +++ b/subscription_oca/__init__.py @@ -1,2 +1,3 @@ from . import models +from . import report from . import wizard diff --git a/subscription_oca/__manifest__.py b/subscription_oca/__manifest__.py index b4206d377b..499e32cd4d 100644 --- a/subscription_oca/__manifest__.py +++ b/subscription_oca/__manifest__.py @@ -24,6 +24,8 @@ "wizard/close_subscription_wizard.xml", "security/subscription_security.xml", "security/ir.model.access.csv", + "security/sale_subscription_report_security.xml", + "report/sale_subscription_report_views.xml", ], "installable": True, "application": True, diff --git a/subscription_oca/readme/USAGE.md b/subscription_oca/readme/USAGE.md index 8f484f45d0..4618381c57 100644 --- a/subscription_oca/readme/USAGE.md +++ b/subscription_oca/readme/USAGE.md @@ -36,3 +36,16 @@ Invoice and delivery addresses: to the sale orders generated by the subscription. - The fields are shown when the *Customer Addresses* setting (group *Display Delivery / Invoice addresses*) is enabled. + +Reporting: + +- Go to *Subscriptions > Reporting* to analyse your recurring revenue + with pivot and graph views: + - *Subscriptions Analysis*: recurring revenue per line, groupable + by customer, template, product, salesperson or start month. + - *MRR Breakdown*: monthly recurring revenue of the running + subscriptions by template and product. + - *Churn Analysis*: closed subscriptions and the revenue lost, + grouped by close reason. +- All amounts are expressed in the company currency, so figures remain + comparable when subscriptions use pricelists in other currencies. diff --git a/subscription_oca/report/__init__.py b/subscription_oca/report/__init__.py new file mode 100644 index 0000000000..b66ded3e6b --- /dev/null +++ b/subscription_oca/report/__init__.py @@ -0,0 +1,2 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from . import sale_subscription_report diff --git a/subscription_oca/report/sale_subscription_report.py b/subscription_oca/report/sale_subscription_report.py new file mode 100644 index 0000000000..ac220744e1 --- /dev/null +++ b/subscription_oca/report/sale_subscription_report.py @@ -0,0 +1,121 @@ +# Copyright 2026 Domatix - Alvaro Domatix +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models, tools + + +class SaleSubscriptionReport(models.Model): + _name = "sale.subscription.report" + _description = "Subscription Analysis" + _auto = False + _rec_name = "subscription_id" + _order = "date_start desc" + + subscription_id = fields.Many2one( + comodel_name="sale.subscription", string="Subscription", readonly=True + ) + partner_id = fields.Many2one( + comodel_name="res.partner", string="Customer", readonly=True + ) + template_id = fields.Many2one( + comodel_name="sale.subscription.template", + string="Subscription template", + readonly=True, + ) + stage_id = fields.Many2one( + comodel_name="sale.subscription.stage", string="Stage", readonly=True + ) + stage_type = fields.Selection( + [ + ("draft", "Draft"), + ("pre", "Ready to start"), + ("in_progress", "In progress"), + ("post", "Closed"), + ], + string="Stage type", + readonly=True, + ) + close_reason_id = fields.Many2one( + comodel_name="sale.subscription.close.reason", + string="Close Reason", + readonly=True, + ) + user_id = fields.Many2one( + comodel_name="res.users", string="Commercial agent", readonly=True + ) + crm_team_id = fields.Many2one( + comodel_name="crm.team", string="Sale team", readonly=True + ) + company_id = fields.Many2one( + comodel_name="res.company", string="Company", readonly=True + ) + company_currency_id = fields.Many2one( + comodel_name="res.currency", string="Company Currency", readonly=True + ) + product_id = fields.Many2one( + comodel_name="product.product", string="Product", readonly=True + ) + categ_id = fields.Many2one( + comodel_name="product.category", string="Product Category", readonly=True + ) + in_progress = fields.Boolean(string="In progress", readonly=True) + to_renew = fields.Boolean(string="To renew", readonly=True) + date_start = fields.Date(string="Start date", readonly=True) + date_end = fields.Date(string="Finish date", readonly=True) + recurring_next_date = fields.Date(string="Next invoice date", readonly=True) + product_uom_qty = fields.Float(string="Quantity", readonly=True) + recurring_monthly = fields.Monetary( + string="Monthly recurring revenue", + currency_field="company_currency_id", + readonly=True, + ) + recurring_yearly = fields.Monetary( + string="Annual recurring revenue", + currency_field="company_currency_id", + readonly=True, + ) + + def _select(self): + return """ + SELECT + line.id AS id, + sub.id AS subscription_id, + sub.partner_id AS partner_id, + sub.template_id AS template_id, + sub.stage_id AS stage_id, + stage.type AS stage_type, + sub.close_reason_id AS close_reason_id, + sub.user_id AS user_id, + sub.crm_team_id AS crm_team_id, + sub.company_id AS company_id, + sub.company_currency_id AS company_currency_id, + line.product_id AS product_id, + template.categ_id AS categ_id, + sub.in_progress AS in_progress, + sub.to_renew AS to_renew, + sub.date_start AS date_start, + sub.date AS date_end, + sub.recurring_next_date AS recurring_next_date, + line.product_uom_qty AS product_uom_qty, + line.recurring_monthly AS recurring_monthly, + line.recurring_monthly * 12.0 AS recurring_yearly + """ + + def _from(self): + return """ + FROM sale_subscription_line line + JOIN sale_subscription sub + ON sub.id = line.sale_subscription_id + LEFT JOIN sale_subscription_stage stage + ON stage.id = sub.stage_id + LEFT JOIN product_product product + ON product.id = line.product_id + LEFT JOIN product_template template + ON template.id = product.product_tmpl_id + WHERE sub.active = TRUE + """ + + def init(self): + tools.drop_view_if_exists(self.env.cr, self._table) + self.env.cr.execute( + f"CREATE OR REPLACE VIEW {self._table} AS ({self._select()} {self._from()})" + ) diff --git a/subscription_oca/report/sale_subscription_report_views.xml b/subscription_oca/report/sale_subscription_report_views.xml new file mode 100644 index 0000000000..c720355255 --- /dev/null +++ b/subscription_oca/report/sale_subscription_report_views.xml @@ -0,0 +1,311 @@ + + + + sale.subscription.report.search + sale.subscription.report + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + sale.subscription.report.pivot + sale.subscription.report + + + + + + + + + + + sale.subscription.report.graph + sale.subscription.report + + + + + + + + + + sale.subscription.report.graph.timeline + sale.subscription.report + + + + + + + + + + sale.subscription.report.list + sale.subscription.report + + + + + + + + + + + + + + + + + + + + + + + + + Subscriptions Analysis + sale.subscription.report + graph,pivot,list + + {"search_default_in_progress": 1} + +

+ No data to analyse yet +

+

+ This analysis aggregates the recurring revenue of your + subscriptions per line, so you can group it by customer, + template, product or salesperson. +

+
+
+ + + MRR Breakdown + sale.subscription.report + pivot,graph,list + + + {"search_default_in_progress": 1, + "pivot_measures": ["recurring_monthly"], + "pivot_row_groupby": ["template_id", "product_id"], + "graph_groupbys": ["template_id"], + "graph_measure": "recurring_monthly"} + + +

+ No recurring revenue yet +

+

+ Breakdown of the monthly recurring revenue of the running + subscriptions by template and product. +

+
+
+ + + MRR Timeline + sale.subscription.report + graph,pivot + + + {"search_default_in_progress": 1, + "search_default_groupby_date_start": 1} + + +

+ No recurring revenue yet +

+

+ Evolution of the monthly recurring revenue over time, accumulated + by the month the subscriptions started. +

+
+
+ + + + graph + + + + + + + pivot + + + + + + Churn Analysis + sale.subscription.report + pivot,graph,list + + [("stage_type", "=", "post")] + + {"search_default_groupby_close_reason_id": 1, + "pivot_measures": ["recurring_monthly", "__count"], + "pivot_row_groupby": ["close_reason_id"]} + + +

+ No closed subscriptions yet +

+

+ Closed subscriptions and the recurring revenue lost, grouped + by close reason. +

+
+
+ + + + + + + + + + +
diff --git a/subscription_oca/security/ir.model.access.csv b/subscription_oca/security/ir.model.access.csv index cd0f7dba90..54a995861e 100644 --- a/subscription_oca/security/ir.model.access.csv +++ b/subscription_oca/security/ir.model.access.csv @@ -6,3 +6,4 @@ access_custom_sale_subscription_stage,sale.subscription.stage,model_sale_subscri access_custom_sale_subscription_line,sale.subscription.line,model_sale_subscription_line,sales_team.group_sale_salesman,1,1,1,1 access_custom_sale_subscription_tag,sale.subscription.tag,model_sale_subscription_tag,sales_team.group_sale_salesman,1,1,1,1 access_close_subscription,Close subscription access,model_close_reason_wizard,sales_team.group_sale_salesman,1,1,1,1 +access_sale_subscription_report,sale.subscription.report,model_sale_subscription_report,sales_team.group_sale_salesman,1,0,0,0 diff --git a/subscription_oca/security/sale_subscription_report_security.xml b/subscription_oca/security/sale_subscription_report_security.xml new file mode 100644 index 0000000000..ce32b41ab0 --- /dev/null +++ b/subscription_oca/security/sale_subscription_report_security.xml @@ -0,0 +1,10 @@ + + + + Subscription analysis: multi-company + + + ["|", ("company_id", "=", False), ("company_id", "in", company_ids)] + + + diff --git a/subscription_oca/static/description/index.html b/subscription_oca/static/description/index.html index 5fcfbe2af6..c65698066f 100644 --- a/subscription_oca/static/description/index.html +++ b/subscription_oca/static/description/index.html @@ -433,6 +433,21 @@

Usage

  • The fields are shown when the Customer Addresses setting (group Display Delivery / Invoice addresses) is enabled.
  • +

    Reporting:

    +
      +
    • Go to Subscriptions > Reporting to analyse your recurring revenue +with pivot and graph views:
        +
      • Subscriptions Analysis: recurring revenue per line, groupable by +customer, template, product, salesperson or start month.
      • +
      • MRR Breakdown: monthly recurring revenue of the running +subscriptions by template and product.
      • +
      • Churn Analysis: closed subscriptions and the revenue lost, +grouped by close reason.
      • +
      +
    • +
    • All amounts are expressed in the company currency, so figures remain +comparable when subscriptions use pricelists in other currencies.
    • +

    Known issues / Roadmap

    diff --git a/subscription_oca/tests/__init__.py b/subscription_oca/tests/__init__.py index db7813c497..ab1fddb36c 100644 --- a/subscription_oca/tests/__init__.py +++ b/subscription_oca/tests/__init__.py @@ -4,3 +4,4 @@ from . import test_subscription_security from . import test_subscription_recurrence_dates from . import test_subscription_partner_addresses +from . import test_subscription_report diff --git a/subscription_oca/tests/test_subscription_report.py b/subscription_oca/tests/test_subscription_report.py new file mode 100644 index 0000000000..f10707fd61 --- /dev/null +++ b/subscription_oca/tests/test_subscription_report.py @@ -0,0 +1,98 @@ +# Copyright 2026 Domatix - Alvaro Domatix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields + +from odoo.addons.base.tests.common import BaseCommon +from odoo.addons.product.tests.common import ProductCommon + + +class TestSubscriptionReport(ProductCommon, BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.pricelist = cls.env["product.pricelist"].create( + {"name": "Report pricelist"} + ) + cls.partner = cls.env["res.partner"].create( + { + "name": "Report partner", + "property_product_pricelist": cls.pricelist.id, + } + ) + cls.product = cls._create_product( + name="Report product", + lst_price=120.0, + subscribable=True, + uom_id=cls.uom_unit.id, + taxes_id=[(6, 0, [])], + ) + cls.template = cls.env["sale.subscription.template"].create( + { + "name": "Report template", + "code": "REP-MTH", + "recurring_rule_type": "months", + "recurring_interval": 1, + } + ) + cls.subscription = cls.env["sale.subscription"].create( + { + "partner_id": cls.partner.id, + "template_id": cls.template.id, + "pricelist_id": cls.pricelist.id, + "date_start": fields.Date.today(), + } + ) + cls.line = cls.env["sale.subscription.line"].create( + { + "sale_subscription_id": cls.subscription.id, + "product_id": cls.product.id, + "product_uom_qty": 2.0, + "price_unit": 120.0, + "tax_ids": [(6, 0, [])], + } + ) + + def _report_rows(self): + self.env.flush_all() + return self.env["sale.subscription.report"].search( + [("subscription_id", "=", self.subscription.id)] + ) + + def test_report_has_one_row_per_line(self): + rows = self._report_rows() + self.assertEqual(len(rows), len(self.subscription.sale_subscription_line_ids)) + + def test_report_row_matches_line_revenue(self): + row = self._report_rows() + self.assertAlmostEqual(row.recurring_monthly, self.line.recurring_monthly, 2) + self.assertAlmostEqual( + row.recurring_yearly, self.line.recurring_monthly * 12.0, 2 + ) + self.assertEqual(row.partner_id, self.partner) + self.assertEqual(row.template_id, self.template) + self.assertEqual(row.product_id, self.product) + self.assertEqual(row.stage_type, self.subscription.stage_id.type) + + def test_report_mrr_aggregation_matches_subscription(self): + self.env["sale.subscription.line"].create( + { + "sale_subscription_id": self.subscription.id, + "product_id": self.product.id, + "product_uom_qty": 1.0, + "price_unit": 60.0, + "tax_ids": [(6, 0, [])], + } + ) + rows = self._report_rows() + self.assertAlmostEqual( + sum(rows.mapped("recurring_monthly")), + self.subscription.recurring_monthly, + 2, + ) + + def test_report_excludes_archived_subscription(self): + self.assertTrue(self._report_rows()) + self.subscription.active = False + self.assertFalse(self._report_rows())