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¶
- SO is the commercial source of truth - all service contracts trace to a Sales Order
- Serial is the entitlement key - service claims are validated by serial, not customer
abs.contractis computed state - likestock.quantfor inventory, it materializes service commitment state- 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:
- B2B / Fleet scenarios - Rental fleet service contracts
- Third-party authorization - Non-owner service purchases
- Voucher/gift system - Service gift redemption
- Ownership transfer tracking - Private sale registration
Related Documentation¶
- Odoo Sales Order as Contract - SO structure and validation
- ABS ServicePlan Integration - Odoo-ABS contract sync
- Odoo Lot/Serial Numbers - Physical asset identity
- Non-Fungibility Concepts - Serial as non-fungible anchor