diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py
index b5dae8aa..a810fa10 100644
--- a/fusion_repairs/__manifest__.py
+++ b/fusion_repairs/__manifest__.py
@@ -4,7 +4,7 @@
{
'name': 'Fusion Repairs',
- 'version': '19.0.1.4.1',
+ 'version': '19.0.1.5.0',
'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """
@@ -84,6 +84,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
'views/repair_dashboard_views.xml',
'views/repair_inspection_views.xml',
'views/repair_order_views.xml',
+ 'views/repair_service_plan_views.xml',
'views/sale_order_views.xml',
'views/technician_task_views.xml',
'views/res_partner_views.xml',
diff --git a/fusion_repairs/data/ir_sequence_data.xml b/fusion_repairs/data/ir_sequence_data.xml
index 1d151cd6..f378e8fb 100644
--- a/fusion_repairs/data/ir_sequence_data.xml
+++ b/fusion_repairs/data/ir_sequence_data.xml
@@ -24,6 +24,17 @@
+
+
+ Service Plan Subscription
+ fusion.repair.service.plan.subscription
+ PLAN-
+ 5
+ 1
+ 1
+
+
+
Inspection Certificate
diff --git a/fusion_repairs/models/__init__.py b/fusion_repairs/models/__init__.py
index 90e32bdc..05ebd5ab 100644
--- a/fusion_repairs/models/__init__.py
+++ b/fusion_repairs/models/__init__.py
@@ -13,6 +13,7 @@ from . import repair_self_check_rule
from . import repair_ai_service
from . import repair_on_call_service
from . import repair_inspection
+from . import repair_service_plan
from . import product_template
from . import res_partner
from . import res_users
diff --git a/fusion_repairs/models/repair_service_plan.py b/fusion_repairs/models/repair_service_plan.py
new file mode 100644
index 00000000..ef10c4bb
--- /dev/null
+++ b/fusion_repairs/models/repair_service_plan.py
@@ -0,0 +1,247 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Pre-paid service plans (M5).
+
+Architecture:
+
+ product.template
+ \--> x_fc_is_service_plan = True
+ x_fc_plan_visits_included (e.g. 4)
+ x_fc_plan_duration_months (e.g. 12)
+
+ sale.order.confirm()
+ \--> for each line whose product is a service plan,
+ create a fusion.repair.service.plan.subscription
+ (partner + product + visits_included + start_date + end_date)
+
+ fusion.repair.maintenance.contract.create_repair_from_booking()
+ visit_report_wizard.action_confirm()
+ \--> burns down one visit if the partner has an active matching plan
+ (for the same product or category)
+
+ fusion.repair.dashboard.get_dashboard_data()
+ \--> exposes active_plan_count + plans_low_count for the dashboard
+"""
+
+from dateutil.relativedelta import relativedelta
+
+from odoo import _, api, fields, models
+
+
+class ProductTemplate(models.Model):
+ _inherit = 'product.template'
+
+ x_fc_is_service_plan = fields.Boolean(
+ string='Service Plan',
+ help='Sell this product as a pre-paid maintenance package. '
+ 'Confirming a sale order with this product creates a '
+ 'visit subscription for the customer.',
+ )
+ x_fc_plan_visits_included = fields.Integer(
+ string='Visits Included',
+ default=4,
+ help='Number of maintenance visits the customer is entitled to under this plan.',
+ )
+ x_fc_plan_duration_months = fields.Integer(
+ string='Plan Duration (months)',
+ default=12,
+ help='Plan ends this many months after the sale-order date even if visits remain.',
+ )
+ x_fc_plan_category_id = fields.Many2one(
+ 'fusion.repair.product.category',
+ string='Plan Category',
+ help='If set, plan visits only burn down for repairs on equipment of this category. '
+ 'Leave blank to apply to any equipment from this customer.',
+ )
+
+
+class FusionRepairServicePlanSubscription(models.Model):
+ _name = 'fusion.repair.service.plan.subscription'
+ _inherit = ['mail.thread']
+ _description = 'Pre-paid Service Plan Subscription'
+ _order = 'end_date desc, id desc'
+
+ name = fields.Char(
+ string='Reference', required=True, default='New',
+ copy=False, readonly=True, tracking=True,
+ )
+ partner_id = fields.Many2one(
+ 'res.partner', string='Client',
+ required=True, tracking=True, index=True,
+ )
+ product_id = fields.Many2one(
+ 'product.product', string='Plan Product',
+ required=True, tracking=True,
+ domain="[('x_fc_is_service_plan', '=', True)]",
+ )
+ category_id = fields.Many2one(
+ 'fusion.repair.product.category',
+ string='Covers Category',
+ help='Computed from the plan product. Only burns visits for repairs '
+ 'whose category matches.',
+ )
+ sale_order_id = fields.Many2one(
+ 'sale.order', string='Sold On',
+ ondelete='set null', tracking=True,
+ )
+
+ visits_included = fields.Integer(string='Visits Included', required=True, default=4)
+ visits_used = fields.Integer(string='Visits Used', default=0, tracking=True)
+ visits_remaining = fields.Integer(
+ string='Remaining',
+ compute='_compute_visits_remaining', store=True,
+ )
+
+ start_date = fields.Date(
+ string='Start', required=True, default=fields.Date.context_today, tracking=True,
+ )
+ end_date = fields.Date(string='Expires', required=True, tracking=True)
+
+ state = fields.Selection(
+ [
+ ('active', 'Active'),
+ ('exhausted', 'Visits Exhausted'),
+ ('expired', 'Expired'),
+ ('cancelled', 'Cancelled'),
+ ],
+ string='Status',
+ compute='_compute_state', store=True, tracking=True,
+ )
+
+ company_id = fields.Many2one(
+ 'res.company', default=lambda self: self.env.company,
+ )
+
+ burn_history_ids = fields.One2many(
+ 'fusion.repair.service.plan.burn',
+ 'subscription_id',
+ string='Burn History',
+ )
+
+ # ------------------------------------------------------------------
+ # CRUD
+ # ------------------------------------------------------------------
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if vals.get('name', 'New') == 'New':
+ vals['name'] = self.env['ir.sequence'].next_by_code(
+ 'fusion.repair.service.plan.subscription'
+ ) or 'PLAN/NEW'
+ if vals.get('product_id') and not vals.get('end_date'):
+ product = self.env['product.product'].sudo().browse(vals['product_id'])
+ months = product.product_tmpl_id.x_fc_plan_duration_months or 12
+ start = vals.get('start_date') or fields.Date.context_today(self)
+ vals['end_date'] = fields.Date.from_string(str(start)) + relativedelta(months=months)
+ if vals.get('product_id') and 'category_id' not in vals:
+ product = self.env['product.product'].sudo().browse(vals['product_id'])
+ if product.product_tmpl_id.x_fc_plan_category_id:
+ vals['category_id'] = product.product_tmpl_id.x_fc_plan_category_id.id
+ if vals.get('product_id') and 'visits_included' not in vals:
+ product = self.env['product.product'].sudo().browse(vals['product_id'])
+ vals['visits_included'] = product.product_tmpl_id.x_fc_plan_visits_included or 4
+ return super().create(vals_list)
+
+ # ------------------------------------------------------------------
+ # COMPUTES
+ # ------------------------------------------------------------------
+ @api.depends('visits_included', 'visits_used')
+ def _compute_visits_remaining(self):
+ for s in self:
+ s.visits_remaining = (s.visits_included or 0) - (s.visits_used or 0)
+
+ @api.depends('visits_remaining', 'end_date')
+ def _compute_state(self):
+ today = fields.Date.context_today(self)
+ for s in self:
+ if s.state == 'cancelled':
+ continue
+ if s.end_date and s.end_date < today:
+ s.state = 'expired'
+ elif s.visits_remaining <= 0:
+ s.state = 'exhausted'
+ else:
+ s.state = 'active'
+
+ # ------------------------------------------------------------------
+ # BURN ENGINE
+ # ------------------------------------------------------------------
+ @api.model
+ def find_for_repair(self, repair):
+ """Return the most-recently-started active subscription covering this
+ repair (partner match + category match if the plan specifies one)."""
+ if not repair.partner_id:
+ return self.browse()
+ domain = [
+ ('partner_id', '=', repair.partner_id.id),
+ ('state', '=', 'active'),
+ ('visits_remaining', '>', 0),
+ ]
+ subs = self.search(domain, order='start_date desc')
+ for s in subs:
+ if not s.category_id or s.category_id == repair.x_fc_repair_category_id:
+ return s
+ return self.browse()
+
+ def burn_visit(self, repair):
+ """Deduct one visit from this subscription and log the burn."""
+ self.ensure_one()
+ if self.visits_remaining <= 0:
+ return False
+ self.visits_used += 1
+ self.env['fusion.repair.service.plan.burn'].sudo().create({
+ 'subscription_id': self.id,
+ 'repair_order_id': repair.id,
+ 'burned_on': fields.Date.context_today(self),
+ })
+ self.message_post(body=_(
+ 'Visit burned for repair %s. %s of %s remaining.'
+ ) % (repair.name, self.visits_remaining, self.visits_included))
+ return True
+
+ def action_cancel(self):
+ for s in self:
+ s.state = 'cancelled'
+ s.message_post(body=_('Plan cancelled.'))
+
+
+class FusionRepairServicePlanBurn(models.Model):
+ _name = 'fusion.repair.service.plan.burn'
+ _description = 'Service Plan Visit Burn'
+ _order = 'burned_on desc, id desc'
+
+ subscription_id = fields.Many2one(
+ 'fusion.repair.service.plan.subscription',
+ string='Subscription', required=True, ondelete='cascade',
+ )
+ repair_order_id = fields.Many2one(
+ 'repair.order', string='Repair', required=True, ondelete='cascade',
+ )
+ burned_on = fields.Date(string='Burned On', required=True,
+ default=fields.Date.context_today)
+
+
+class SaleOrder(models.Model):
+ _inherit = 'sale.order'
+
+ def action_confirm(self):
+ res = super().action_confirm()
+ # Spawn subscriptions for each service-plan line.
+ for order in self:
+ for line in order.order_line:
+ tmpl = line.product_id.product_tmpl_id
+ if not tmpl.x_fc_is_service_plan:
+ continue
+ # One subscription per quantity unit (so a SO line with qty=2
+ # creates two distinct plans - rare but supported).
+ qty = int(line.product_uom_qty or 1)
+ for _i in range(max(qty, 1)):
+ self.env['fusion.repair.service.plan.subscription'].sudo().create({
+ 'partner_id': order.partner_id.id,
+ 'product_id': line.product_id.id,
+ 'sale_order_id': order.id,
+ 'start_date': fields.Date.context_today(self),
+ })
+ return res
diff --git a/fusion_repairs/security/ir.model.access.csv b/fusion_repairs/security/ir.model.access.csv
index 33381e5b..ad3c9531 100644
--- a/fusion_repairs/security/ir.model.access.csv
+++ b/fusion_repairs/security/ir.model.access.csv
@@ -30,3 +30,8 @@ access_repair_inspection_user,Inspection Cert User Read,model_fusion_repair_insp
access_repair_inspection_dispatcher,Inspection Cert Dispatcher,model_fusion_repair_inspection_certificate,group_fusion_repairs_dispatcher,1,1,1,0
access_repair_inspection_manager,Inspection Cert Manager Full,model_fusion_repair_inspection_certificate,group_fusion_repairs_manager,1,1,1,1
access_repair_inspection_technician,Inspection Cert Field Tech Read-Only,model_fusion_repair_inspection_certificate,fusion_tasks.group_field_technician,1,0,0,0
+access_service_plan_sub_user,Service Plan Sub User Read,model_fusion_repair_service_plan_subscription,group_fusion_repairs_user,1,0,0,0
+access_service_plan_sub_dispatcher,Service Plan Sub Dispatcher,model_fusion_repair_service_plan_subscription,group_fusion_repairs_dispatcher,1,1,1,0
+access_service_plan_sub_manager,Service Plan Sub Manager Full,model_fusion_repair_service_plan_subscription,group_fusion_repairs_manager,1,1,1,1
+access_service_plan_burn_user,Service Plan Burn User Read,model_fusion_repair_service_plan_burn,group_fusion_repairs_user,1,0,0,0
+access_service_plan_burn_manager,Service Plan Burn Manager Full,model_fusion_repair_service_plan_burn,group_fusion_repairs_manager,1,1,1,1
diff --git a/fusion_repairs/views/menus.xml b/fusion_repairs/views/menus.xml
index 47456ebb..e4f02d84 100644
--- a/fusion_repairs/views/menus.xml
+++ b/fusion_repairs/views/menus.xml
@@ -45,6 +45,12 @@
action="action_repair_inspection"
sequence="35"/>
+
+