Skip to content

Odoo Sales Order as Contract

Purpose: Define the Sales Order as a contract envelope with line-level semantics for asset-service binding.

Audience: Developers, operations teams, ABS integration engineers.

Design Status: B2C Validated Model (v2.0)


A. Foundational Principle

Odoo as SPOT for Physical Assets and Contractual Bindings

Odoo serves as the Single Point of Truth (SPOT) for:

  • Physical asset identity
  • Physical movement history
  • Serial-number lifecycle
  • Commercial contracts and amendments

Core Odoo Models

  • stock.lot (serial number) - Canonical representation of a specific, non-fungible asset
  • sale.order - Canonical representation of a commercial contract over time
  • stock.move.line - Record of which serial fulfilled which contractual obligation
  • abs.contract - Computed state of service commitments (like stock.quant for inventory)

ABS consumes bindings authored and persisted in Odoo; it never infers bindings independently.


B. "Swap Privilege" as Composite Commercial Right

"Swap privilege" is not a simple service—it decomposes into three implied bindings:

B1. Asset-Class Compatibility

The motorbike model implies: * Battery form factor * Electrical interface * Safety envelope

This is product-model logic, not runtime logic, and belongs in: * Product definition / category rules * Service terms metadata

B2. Serial-Scoped Entitlement

A specific motorbike serial number entitles: * The holder * During a defined period * To access a defined battery circulation pool * And a defined swap station network

This service–serial binding must be: * Explicit * Queryable * Time-bounded

B3. Ownership vs Access (Rental Case)

In rental scenarios: * No sale occurs * Asset remains on company balance sheet * Physical issuance occurs via contract

In Odoo terms: * Serial exists independently of sale * sale.order expresses right of use, not transfer of ownership * Physical movement may occur without revenue recognition

Principle: Serial numbers are asset identities, not sale artifacts.


C. Multi-Service Binding to One Serial

One serial → many services is the norm, not an edge case.

Examples bound to the same serial: * Warranty * Anti-theft / tracking * Swap privilege * Extended maintenance * Insurance-like coverage

Odoo Implementation

  • Services are sale.order.line entries
  • Binding is per line, per serial
  • abs.contract records are one per (service × serial), not one per order
  • Multiple contracts of the same type ARE allowed (different validity periods)

D. Sales Order as Time-Persistent Contract

D1. Sales Order = Contract Envelope

sale.order persists and anchors: * Commercial intent * Amendments * References

It does not natively enforce time or recurrence.

D2. Contract Semantics Are Line-Level

Each sale.order.line has its own: * Effective date * Duration * Termination logic

Warranty, subscription, rental, and privilege cannot be safely modeled as order-level attributes.

D3. Selective Use of Contract/Subscription Constructs

Use Odoo's contract/subscription constructs only where temporal behavior (recurrence, renewal, suspension) exists.

Do not force all service bindings into the subscription model.


E. Two Types of Sales Orders

E1. Bundle SO (Physical Product + Services)

Contains: * ONE physical product line (serial-tracked) at order_line[0] * Zero or more service product lines

Serial flows from physical line through delivery to service contracts.

E2. Service-Only SO

Contains: * ONLY service product lines * MUST reference original Bundle SO via source_so_id field * target_lot_id is computed from source_so_id

Used when customer purchases additional services for an asset they already own.

Key constraint: Service-only SO is BASED on the original Bundle SO, enabling: - Direct FK to original transaction - Temporal anchor for eligibility rules (source_so_id.date_order) - Implicit ownership inheritance (source_so_id.partner_id) - Audit trail of service purchase lineage


F. Serial Number Flow Architecture

F1. Key Principle: Service Products Have NO Serial Field

Service products are intangible. They do NOT use stock tracking methods.

IMPORTANT: There is no service_lot_id field on service SO lines.

F2. Serial Flow for Bundle SO

sale.order.line (physical)
    ↓ confirmation + delivery
stock.move
    ↓
stock.move.line
    ↓
lot_id (the actual serial)
    ↓ computed field
sale.order.line.fulfilled_lot_id
    ↓ contract creation
abs.contract.physical_lot_id

F3. Serial Flow for Service-Only SO

sale.order.target_lot_id (pre-existing serial)
    ↓ validation (ownership check)
    ↓ contract creation
abs.contract.physical_lot_id

G. SO Line Computed Field: fulfilled_lot_id

This computed field retrieves the serial from completed delivery:

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

    fulfilled_lot_id = fields.Many2one('stock.lot',
        compute='_compute_fulfilled_lot',
        store=True,
        help='Serial number assigned from delivery (stock.move.line)'
    )

    @api.depends('move_ids.move_line_ids.lot_id')
    def _compute_fulfilled_lot(self):
        for line in self:
            # Traverse: SO line → stock.move → stock.move.line → lot_id
            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

Why this matters: - Uses Odoo's native sale.order.linestock.movestock.move.line relationships - Serial becomes available AFTER delivery, not at draft time - Computed field provides efficient query path


H. Service-Only SO: source_so_id (Primary) and target_lot_id (Computed)

For orders containing ONLY service products:

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

    # Service-only SO MUST reference 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
    target_lot_id = fields.Many2one('stock.lot',
        compute='_compute_target_lot',
        store=True,
        string='Target Asset Serial'
    )

    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

I. Service Product Configuration

I.1. Pricing Integration

Requirement: Service products participate in standard Odoo pricelist regime.

Implementation: Service products are standard product.product records with: - Full product.pricelist.item support - Currency conversion - Customer-specific pricing - Volume/time-based discounts

I.2. Product Category Structure

Required hierarchy:

All Products
├── Physical Goods
│   ├── Motorcycles
│   ├── Batteries
│   └── Accessories
└── Service Products
    ├── Warranties
    ├── Swap Privileges
    ├── Maintenance Plans
    └── Subscriptions

I.3. Service-Product Compatibility and Eligibility

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

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

    service_transferable = fields.Boolean(
        default=False,
        help='If True, service follows the asset regardless of owner. '
             'If False, only original purchaser can claim service.'
    )

    # 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
    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 Service Product Configuration

Product Mode Max Days Requires Prior
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

J. SO Validation Rules

Rule 1: Bundle SO - Single Physical Product

@api.constrains('order_line')
def _check_bundle_so_single_physical(self):
    """Bundle SO must have exactly ONE physical serial-tracked product."""
    if self.is_service_only:
        return  # Not a bundle SO

    service_lines = self.order_line.filtered(
        lambda l: l.product_id.categ_id.complete_name.startswith('Service Products')
    )

    if not service_lines:
        return  # No services, no constraint

    physical_lines = self.order_line.filtered(
        lambda l: l.product_id.type == 'product' 
               and l.product_id.tracking == 'serial'
    )

    if len(physical_lines) != 1:
        raise ValidationError(
            f'Bundle orders with service products must contain exactly '
            f'ONE physical product with serial tracking.\n\n'
            f'Found: {len(physical_lines)} physical products.\n\n'
            f'For multiple assets, create separate orders.'
        )

Rule 2: Service-Product Type Compatibility

@api.constrains('order_line')
def _check_service_product_compatibility(self):
    """Service products must be compatible with physical product."""
    service_lines = self.order_line.filtered(
        lambda l: l.product_id.categ_id.complete_name.startswith('Service Products')
    )

    if not service_lines:
        return

    # For Bundle SO: get physical product
    # For Service-only SO: get product from target_lot_id
    if self.is_service_only:
        if not self.target_lot_id:
            return
        physical_product = self.target_lot_id.product_id
    else:
        physical_line = self.order_line.filtered(
            lambda l: l.product_id.type == 'product' 
                   and l.product_id.tracking == 'serial'
        )
        if not physical_line:
            return
        physical_product = physical_line[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}".'
            )

Rule 3: Service-Only SO - Ownership & Temporal Validation (B2C)

@api.constrains('source_so_id', 'partner_id', 'order_line')
def _check_service_only_so_rules(self):
    """B2C: Validate service-only SO against original purchase."""
    if not self.is_service_only:
        return

    # Rule 3a: 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 3b: Ownership continuity
    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 instead.'
        )

    # Rule 3c: 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

        # Purchase mode check
        if product.service_purchase_mode == 'bundle_only':
            raise ValidationError(
                f'"{product.name}" can only be purchased with a new product.\n'
                f'For existing assets, look for an "Extended" or "Renewal" version.'
            )

        # Time window check
        max_days = product.eligible_max_days_after_delivery or 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)'
            )

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

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

Rule 4: Service-Only SO Must Have Source SO

@api.constrains('is_service_only', 'source_so_id')
def _check_service_only_requires_source(self):
    """Service-only SO must specify source SO."""
    if self.is_service_only and not self.source_so_id:
        raise ValidationError(
            'Service-only orders must reference the Original Purchase Order.\n\n'
            'Which original order sold the physical asset?'
        )

K. Contract Creation Trigger

K1. Bundle SO: After Delivery

class StockPicking(models.Model):
    _inherit = 'stock.picking'

    def _action_done(self):
        res = super()._action_done()

        # Check if this picking completes a Bundle SO with services
        for picking in self:
            sale_order = picking.sale_id
            if not sale_order or sale_order.is_service_only:
                continue

            # Check if all pickings are done
            if sale_order.picking_ids.filtered(lambda p: p.state != 'done'):
                continue

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

            physical_line = sale_order.order_line.filtered(
                lambda l: l.fulfilled_lot_id
            )

            if physical_line and service_lines:
                for svc_line in service_lines:
                    self.env['abs.contract'].create_from_so_line(
                        svc_line, 
                        physical_line[0].fulfilled_lot_id
                    )

        return res

K2. Service-Only SO: After Confirmation

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

    def action_confirm(self):
        res = super().action_confirm()

        for order in self:
            if not order.is_service_only:
                continue

            # Create abs.contract for each service line
            for line in order.order_line:
                self.env['abs.contract'].create_from_so_line(
                    line,
                    order.target_lot_id
                )

        return res

L. UI Enhancements

L1. SO Form View

<record id="view_order_form_service_extension" model="ir.ui.view">
    <field name="name">sale.order.form.service.ext</field>
    <field name="model">sale.order</field>
    <field name="inherit_id" ref="sale.view_order_form"/>
    <field name="arch" type="xml">
        <xpath expr="//field[@name='partner_id']" position="after">
            <field name="is_service_only" invisible="1"/>
            <field name="source_so_id" 
                   string="Original Purchase Order"
                   attrs="{'invisible': [('is_service_only', '=', False)],
                           'required': [('is_service_only', '=', True)]}"
                   options="{'no_create': True}"/>
            <field name="target_lot_id" 
                   string="Target Asset Serial"
                   readonly="1"
                   attrs="{'invisible': [('is_service_only', '=', False)]}"/>
        </xpath>
    </field>
</record>

L2. SO Line Tree View

<xpath expr="//field[@name='product_id']" position="after">
    <field name="fulfilled_lot_id" 
           string="Serial"
           readonly="1"
           attrs="{'invisible': [('product_id.type', '!=', 'product')]}"/>
</xpath>

M. Summary: What Changed from Previous Design

Aspect OLD (Incorrect) NEW (Validated)
Service line serial field service_lot_id on SO line NO field - serial comes from physical line
Serial lookup Direct lot_id on SO line Computed fulfilled_lot_id from stock.move.line
Service-only SO reference target_lot_id only source_so_id (FK to original SO)
target_lot_id Manual input Computed from source_so_id
Ownership validation Search for original SO Direct FK via source_so_id.partner_id
Temporal eligibility Not addressed source_so_id.date_order as anchor
Contract creation trigger On SO confirmation Bundle: after delivery; Service-only: on confirmation
Ownership model Not specified B2C: Only owner can purchase