Skip to content

Service Bundles: Physical Goods + Asset-Bound Services

Purpose: Define how to represent bundles combining physical goods with service contracts that bind to specific assets in Odoo, while maintaining integration with the ABS (Asset-Based Services) platform.

Audience: Developers, system architects, operations teams implementing asset-service binding.

Version: 2.0 (Validated B2C Model)


1. Product Domain Separation

The architecture maintains strict semantic and accounting separation between two product domains:

Physical Goods

  • Tangible products (electric motorcycles, home energy systems)
  • Managed under the Physical Goods branch of the product category tree
  • Participate in standard Odoo workflows:
  • Inventory moves
  • BoM management
  • Cost of Goods Sold (COGS) roll-up
  • Pricing rules
  • Sales → Invoice → Payment lifecycle
  • Tracked at individual item level (VIN, Product Item ID, serial/lot)

Service Products

  • Intangible, long-lived offerings (subscriptions, warranties, privileges, entitlements)
  • Managed under the Service Products branch of the product category tree
  • Characterized by:
  • Duration or lifecycle
  • Ongoing contractual meaning rather than inventory movement
  • No stock tracking (type = 'service', tracking = 'none')
  • Examples: warranties, swap privileges, service subscriptions

2. Core Architecture

2.1. Design Principles

  1. SO is the commercial source of truth - all service contracts trace to a Sales Order
  2. Serial is the entitlement key - service claims are validated by serial, not customer
  3. abs.contract is computed state - like stock.quant for inventory, it materializes service commitment state
  4. Commercial policy is separate from data model - transferability, ownership rules encoded in product configuration

2.2. Key Data Flow

sale.order (SO) → sale.order.line (service) → abs.contract (computed state)
                                                    ↓
                                              ABS ServicePlan (execution)

2.3. Serial Binding

For Bundle SO (physical + services): - Serial assigned to physical product via delivery (stock.move.line.lot_id) - Computed field fulfilled_lot_id on SO line captures this - abs.contract.physical_lot_id copies serial at creation time

For Service-only SO: - sale.order.source_so_id links to original Bundle SO - target_lot_id is computed from source_so_id.order_line.fulfilled_lot_id - abs.contract.physical_lot_id copies from computed target_lot_id - Temporal/eligibility logic anchored to source_so_id.date_order


3. Product Category Structure

Required hierarchy:

All Products
├── Physical Goods
│   ├── Motorcycles
│   ├── Batteries
│   └── Accessories
└── Service Products
    ├── Warranties
    │   ├── E3Pro Warranty
    │   ├── E5Pro Warranty
    │   └── Battery Warranty
    ├── Swap Privileges
    │   ├── E3Pro Swap Service
    │   └── E5Pro Swap Service
    ├── Maintenance Plans
    │   ├── Basic Service Plan
    │   └── Premium Service Plan
    └── Subscriptions
        ├── Tracking Service
        └── Anti-Theft Service

4. Service Product Configuration

class ProductProduct(models.Model):
    _inherit = 'product.product'

    # Compatibility constraint
    compatible_product_ids = fields.Many2many(
        'product.product',
        'product_service_compatibility_rel',
        'service_id', 'product_id',
        string='Compatible Physical Products',
        help='Which physical products this service can be sold with',
        domain="[('type', '=', 'product'), ('tracking', '=', 'serial')]"
    )

    # Commercial policy
    service_transferable = fields.Boolean(
        default=True,
        help='Can service be claimed by someone other than original buyer?'
    )

    # Contract configuration
    service_duration_days = fields.Integer(
        help='Default contract duration in days'
    )

    # Purchase mode constraint
    service_purchase_mode = fields.Selection([
        ('bundle_only', 'Bundle SO Only'),
        ('service_only', 'Service-Only SO Only'),
        ('both', 'Both Modes Allowed')
    ], default='both',
       help='Where can this service be purchased?'
    )

    # Temporal eligibility (for post-delivery purchases)
    eligible_max_days_after_delivery = fields.Integer(
        default=0,
        help='Max days after original delivery to purchase. 0 = no limit.'
    )

    # Prerequisite service
    requires_prior_service_id = fields.Many2one(
        'product.product',
        string='Requires Prior Service',
        help='Customer must have active/fulfilled contract of this type first'
    )

Example Product Configuration

Product service_purchase_mode eligible_max_days requires_prior_service_id
E3Pro Warranty (New) bundle_only - -
E3Pro Extended Warranty service_only 30 E3Pro Warranty (New)
E3Pro Swap Service both 0 -
E3Pro Swap Renewal service_only 0 E3Pro Swap Service

5. Sales Order Extensions

5.1. SO-Level Fields for Service-Only SO

class SaleOrder(models.Model):
    _inherit = 'sale.order'

    # Service-only SO MUST reference the original Bundle SO
    source_so_id = fields.Many2one('sale.order',
        string='Original Purchase Order',
        help='The Bundle SO that originally sold the physical asset. '
             'Required for service-only orders.',
        domain="[('state', 'in', ['sale', 'done'])]"
    )

    # Computed from source_so_id - the serial from original purchase
    target_lot_id = fields.Many2one('stock.lot',
        compute='_compute_target_lot',
        store=True,
        string='Target Asset Serial',
        help='Derived from source_so_id - the serial that was delivered'
    )

    # Computed: is this a service-only SO?
    is_service_only = fields.Boolean(
        compute='_compute_is_service_only',
        store=True
    )

    @api.depends('order_line.product_id.type')
    def _compute_is_service_only(self):
        for order in self:
            has_physical = any(
                line.product_id.type == 'product' 
                for line in order.order_line
            )
            order.is_service_only = not has_physical and bool(order.order_line)

    @api.depends('source_so_id', 'source_so_id.order_line.fulfilled_lot_id')
    def _compute_target_lot(self):
        for order in self:
            if order.source_so_id:
                physical_line = order.source_so_id.order_line.filtered(
                    lambda l: l.fulfilled_lot_id
                )
                order.target_lot_id = physical_line[0].fulfilled_lot_id if physical_line else False
            else:
                order.target_lot_id = False

5.2. SO Line Computed Field

class SaleOrderLine(models.Model):
    _inherit = 'sale.order.line'

    # Computed from delivery - for physical products only
    fulfilled_lot_id = fields.Many2one('stock.lot',
        compute='_compute_fulfilled_lot',
        store=True,
        help='Serial number from delivery'
    )

    @api.depends('move_ids.move_line_ids.lot_id')
    def _compute_fulfilled_lot(self):
        for line in self:
            move_lines = line.move_ids.mapped('move_line_ids')
            lots = move_lines.filtered(lambda ml: ml.lot_id).mapped('lot_id')
            line.fulfilled_lot_id = lots[0] if lots else False

6. SO Types and Validation Rules

6.1. Bundle SO (Physical + Services)

Validation rules:

class SaleOrder(models.Model):
    _inherit = 'sale.order'

    @api.constrains('order_line')
    def _check_bundle_so_rules(self):
        """Validate Bundle SO: physical + services."""
        service_lines = self.order_line.filtered(
            lambda l: l.product_id.categ_id.complete_name.startswith('Service Products')
        )

        if not service_lines:
            return  # Not a service SO

        if self.target_lot_id:
            return  # Service-only SO, different validation

        # Rule 1: Exactly 1 physical product with serial tracking
        physical_lines = self.order_line.filtered(
            lambda l: l.product_id.type == 'product' 
                   and l.product_id.tracking == 'serial'
        )

        if len(physical_lines) == 0:
            raise ValidationError(
                'Bundle SO with services must include one physical product '
                'with serial tracking, OR specify target_lot_id for service-only SO.'
            )

        if len(physical_lines) > 1:
            raise ValidationError(
                'Bundle SO can contain only ONE physical product. '
                'Split multiple assets into separate orders.'
            )

        # Rule 2: Service-product compatibility
        physical_product = physical_lines[0].product_id

        for svc_line in service_lines:
            compatible = svc_line.product_id.compatible_product_ids
            if compatible and physical_product not in compatible:
                raise ValidationError(
                    f'Service "{svc_line.product_id.name}" is not compatible '
                    f'with "{physical_product.name}".'
                )

6.2. Service-only SO

Key requirement: Service-only SO MUST be based on the original Bundle SO via source_so_id.

This provides: - Direct FK to original transaction (no search needed) - Temporal anchor for eligibility rules - Implicit ownership inheritance - Audit trail of service purchase lineage

Validation rules:

@api.constrains('source_so_id', 'partner_id', 'order_line')
def _check_service_only_so_rules(self):
    """Validate Service-only SO."""
    if not self.is_service_only:
        return  # Not a service-only SO

    # Rule 1: Must reference original SO
    if not self.source_so_id:
        raise ValidationError(
            'Service-only orders must reference the Original Purchase Order.\n\n'
            'Select the order that originally sold the physical asset.'
        )

    # Rule 2: Ownership continuity (B2C)
    if self.partner_id != self.source_so_id.partner_id:
        raise ValidationError(
            f'Service-only orders must be for the same customer as the '
            f'original purchase.\n\n'
            f'Original customer: {self.source_so_id.partner_id.name}\n'
            f'Current customer: {self.partner_id.name}\n\n'
            f'For gifts, use a service voucher.'
        )

    # Rule 3: Temporal eligibility
    original_date = self.source_so_id.date_order.date()
    days_elapsed = (fields.Date.today() - original_date).days

    for line in self.order_line:
        product = line.product_id
        max_days = getattr(product, 'eligible_max_days_after_delivery', 0)

        if max_days > 0 and days_elapsed > max_days:
            raise ValidationError(
                f'"{product.name}" must be purchased within {max_days} days '
                f'of original purchase.\n\n'
                f'Original purchase: {original_date} ({days_elapsed} days ago)'
            )

        # Check prerequisite service
        requires_prior = getattr(product, 'requires_prior_service_id', None)
        if requires_prior:
            prior_contract = self.env['abs.contract'].search([
                ('physical_lot_id', '=', self.target_lot_id.id),
                ('service_product_id', '=', requires_prior.id),
                ('state', 'in', ['active', 'fulfilled'])
            ], limit=1)

            if not prior_contract:
                raise ValidationError(
                    f'"{product.name}" requires prior purchase of '
                    f'"{requires_prior.name}".'
                )

    # Rule 4: Service compatibility with original physical product
    physical_line = self.source_so_id.order_line.filtered(
        lambda l: l.fulfilled_lot_id
    )

    if physical_line:
        physical_product = physical_line[0].product_id

        for svc_line in self.order_line:
            compatible = svc_line.product_id.compatible_product_ids
            if compatible and physical_product not in compatible:
                raise ValidationError(
                    f'Service "{svc_line.product_id.name}" is not compatible '
                    f'with "{physical_product.name}".'
                )

7. abs.contract Model (Computed State)

7.1. Model Definition

abs.contract is analogous to stock.quant - a materialized state derived from transactions.

class ABSContract(models.Model):
    _name = 'abs.contract'
    _description = 'Asset-Based Service Contract (Computed State)'

    # Identity
    contract_number = fields.Char(required=True, readonly=True)

    # Source transaction (immutable)
    so_line_id = fields.Many2one('sale.order.line', 
        required=True,
        string='Source Order Line'
    )

    # Denormalized for query performance
    physical_lot_id = fields.Many2one('stock.lot',
        required=True,
        string='Asset Serial Number',
        help='Copied at creation time for fast lookup'
    )

    service_product_id = fields.Many2one('product.product',
        required=True,
        string='Service Type'
    )

    partner_id = fields.Many2one('res.partner',
        required=True,
        string='Original Customer'
    )

    # Contract lifecycle
    state = fields.Selection([
        ('active', 'Active'),
        ('suspended', 'Suspended'),
        ('fulfilled', 'Fulfilled'),
        ('expired', 'Expired'),
        ('cancelled', 'Cancelled')
    ], default='active', required=True)

    start_date = fields.Date(required=True)
    end_date = fields.Date()

    # Financial
    provision_cost = fields.Monetary(
        string='Estimated Service Cost',
        help='Expected COGS for fulfilling this contract'
    )
    currency_id = fields.Many2one('res.currency')

    # ABS integration
    abs_serviceplan_ref = fields.Char(
        string='ABS ServicePlan ID',
        help='Reference to ServicePlan in ABS system'
    )

    # Operational extensions
    service_center_id = fields.Many2one('res.partner',
        domain="[('is_service_center', '=', True)]",
        string='Assigned Service Center'
    )

    # Service history
    service_event_ids = fields.One2many('abs.contract.event', 'contract_id',
        string='Service Events'
    )

7.2. Contract Creation

class SaleOrder(models.Model):
    _inherit = 'sale.order'

    def action_confirm(self):
        """Override to create abs.contract on confirmation."""
        res = super().action_confirm()

        for order in self:
            order._create_service_contracts()

        return res

    def _create_service_contracts(self):
        """Create abs.contract records for service lines."""
        service_lines = self.order_line.filtered(
            lambda l: l.product_id.categ_id.complete_name.startswith('Service Products')
        )

        if not service_lines:
            return

        # Get serial from physical line or target_lot_id
        serial = self._get_service_serial()

        if not serial:
            raise ValidationError('Cannot create contracts: no serial assigned.')

        for line in service_lines:
            self.env['abs.contract'].create({
                'contract_number': self.env['ir.sequence'].next_by_code('abs.contract'),
                'so_line_id': line.id,
                'physical_lot_id': serial.id,
                'service_product_id': line.product_id.id,
                'partner_id': self.partner_id.id,
                'state': 'active',
                'start_date': self.date_order.date(),
                'end_date': self.date_order.date() + timedelta(
                    days=line.product_id.service_duration_days or 365
                ),
                'provision_cost': line.product_id.standard_price,
                'currency_id': self.currency_id.id
            })

    def _get_service_serial(self):
        """Get serial for contract creation."""
        # Service-only SO
        if self.target_lot_id:
            return self.target_lot_id

        # Bundle SO - from physical line delivery
        physical_line = self.order_line.filtered(
            lambda l: l.product_id.type == 'product' and l.fulfilled_lot_id
        )

        return physical_line[0].fulfilled_lot_id if physical_line else False

8. Service Claim Validation

def validate_service_claim(serial_name, service_type, claimant_partner):
    """
    Validate if a service claim is legitimate.

    Args:
        serial_name: Serial number of the asset
        service_type: product.product ID of the service
        claimant_partner: res.partner making the claim

    Returns:
        (bool, str): (is_valid, message)
    """
    # Find active contract
    contract = env['abs.contract'].search([
        ('physical_lot_id.name', '=', serial_name),
        ('service_product_id', '=', service_type),
        ('state', '=', 'active'),
        ('start_date', '<=', fields.Date.today()),
        '|',
        ('end_date', '=', False),
        ('end_date', '>=', fields.Date.today())
    ], limit=1)

    if not contract:
        return False, "No active contract for this serial and service type."

    # Check transferability
    if not contract.service_product_id.service_transferable:
        if claimant_partner != contract.partner_id:
            return False, (
                f"Non-transferable service. "
                f"Only {contract.partner_id.name} can claim."
            )

    return True, "Claim valid."

9. SO Cancellation → Contract Cancellation

class SaleOrder(models.Model):
    _inherit = 'sale.order'

    def action_cancel(self):
        """Cancel associated contracts when SO is cancelled."""
        res = super().action_cancel()

        for order in self:
            contracts = self.env['abs.contract'].search([
                ('so_line_id.order_id', '=', order.id),
                ('state', '=', 'active')
            ])

            contracts.write({'state': 'cancelled'})

            # Emit cancellation events to ABS
            for contract in contracts:
                contract._emit_abs_termination('cancelled')

        return res

10. Multiple Contracts on Same Serial

Allowed: Multiple contracts of the same service type on the same serial.

Use case: Customer buys E3Pro + 12-month warranty, later buys additional 12-month warranty.

Result: - Two abs.contract records - Same physical_lot_id - Same service_product_id - Different so_line_id, start_date, end_date - May overlap

Claim validation: Returns first active contract found.


11. Deferred Features (Not B2C)

The following are NOT implemented in the current B2C model:

  1. B2B / Fleet scenarios - Rental fleet service contracts
  2. Third-party authorization - Non-owner service purchases
  3. Voucher/gift system - Service gift redemption
  4. Ownership transfer tracking - Private sale registration