Skip to content

ABS ServicePlan: Contract Execution Architecture

Purpose: Define the integration contract between Odoo (commercial master) and ABS (execution engine) for asset-based service contracts.

Audience: System architects, integration developers, platform engineers implementing Odoo-ABS synchronization.

Design Status: B2C Validated Model (v2.0)


1. Architecture Overview

The architecture treats: - Sales Order as the contract-of-record (transaction) - abs.contract as computed state (like stock.quant for inventory) - ABS ServicePlan as the execution engine

Key Insight: abs.contract as Computed State

abs.contract is NOT a transaction record. It is a materialized state table that: - Consolidates service commitments from SO transactions - Enables efficient liability queries - Provides stable query path for serial → services - Mirrors the relationship between stock.move (transaction) and stock.quant (state)


2. Canonical Role Split

Odoo (Contract-of-Record)

Odoo asserts and persists the commercial truth:

  • What was contracted: sale.order + sale.order.line (goods + services)
  • Who it is with: res.partner
  • When it is in force: order status + line-level effective dates/terms
  • Which specific asset(s) are in scope: serial binding per line
  • Commercial amendments: replacements, upgrades, cancellations

Odoo's authoritative statement:

"There is a contract in force."

ABS (Execution-of-Record)

ABS asserts and persists the operational truth:

  • ServicePlan instances created from Odoo's contract lines
  • Asset enforcement (IoT, swap gating, tracking service enablement)
  • Operational state and telemetry ("active", "suspended", "fault", "violations", "usage")
  • Entitlement resolution ("this VIN can access this battery pool / station network")

ABS's authoritative response:

"Yes—here are the details."


3. The Minimal Shared Contract Language

3.1. Shared Identifiers (Non-negotiable)

Identifier Source Description
contract_ref sale.order.name Contract envelope reference
contract_line_ref sale.order.line.id Specific contractual obligation
abs_contract_id abs.contract.contract_number Computed state record
asset_ref stock.lot.name Non-fungible asset identity
customer_ref res.partner.id Customer identity

3.2. Shared Lifecycle (Two-layer State)

Layer A: Commercial Validity (Odoo master)

Draft → Confirmed → In Force → Ended / Cancelled / Replaced

Layer B: Operational Execution (ABS master)

Planned → Provisioned → Active → Suspended / Faulted → Terminated

This gives clean bidirectional statements: * Odoo: "Contract X is In Force for Serial Y." * ABS: "ServicePlan(X,Y) is Active, with these operational parameters."


4. Serial Number Flow (Critical Path)

4.1. Bundle SO Serial Flow

sale.order.line (physical product)
    ↓ confirmation creates stock.picking
stock.move
    ↓ delivery assigns serial
stock.move.line.lot_id
    ↓ computed field
sale.order.line.fulfilled_lot_id
    ↓ delivery completion trigger
abs.contract.physical_lot_id
    ↓ sync to ABS
ServicePlan.asset_ref

IMPORTANT: Service SO lines have NO serial field. The serial comes from: 1. Physical line's fulfilled_lot_id (Bundle SO) 2. SO-level target_lot_id (Service-only SO)

4.2. Service-Only SO Serial Flow

sale.order.source_so_id (FK to original Bundle SO)
    ↓ computed field
sale.order.target_lot_id (derived from source_so_id)
    ↓ ownership + temporal validation
    ↓ confirmation trigger
abs.contract.physical_lot_id
    ↓ sync to ABS
ServicePlan.asset_ref

Key point: Service-only SO links to original Bundle SO via source_so_id. The target_lot_id is computed, not user-entered. This enables: - Direct FK relationship (no search needed) - Temporal anchor via source_so_id.date_order - Implicit ownership inheritance


5. Product Configuration Prerequisites

5.1. Service Product Pricing

Constraint: Service products MUST participate in standard Odoo product.pricelist regime.

Rationale: Integration with existing commercial workflows requires: - Customer-specific pricing - Currency conversion - Volume/time-based discounts - Category-based pricelist rules

5.2. Product Category Hierarchy

All Products
├── Physical Goods (inventory-tracked)
└── Service Products (contract-tracked)
    ├── Warranties
    ├── Swap Privileges
    ├── Maintenance Plans
    └── Subscriptions

5.3. Service Product Fields

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

    # Compatibility constraint
    compatible_product_ids = fields.Many2many(
        'product.product',
        string='Compatible Products'
    )

    # Commercial policy (NOT a modeling constraint)
    service_transferable = fields.Boolean(
        default=False,
        help='If True, service follows asset regardless of owner'
    )

    # Duration configuration
    service_duration_months = fields.Integer(
        default=12,
        help='Default contract duration in months'
    )

    # 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'
    )

5.4. 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

6. The abs.contract Model

6.1. Model Definition

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

    # Identity
    contract_number = fields.Char(
        required=True, 
        readonly=True,
        copy=False,
        default=lambda self: self.env['ir.sequence'].next_by_code('abs.contract')
    )

    # Source linkage
    so_line_id = fields.Many2one('sale.order.line', 
        required=True,
        ondelete='restrict',
        help='Originating SO line'
    )

    # The binding (denormalized for query safety)
    physical_lot_id = fields.Many2one('stock.lot', 
        required=True,
        index=True,
        help='Serial number this contract covers'
    )

    service_product_id = fields.Many2one('product.product', 
        required=True,
        help='Type of service'
    )

    partner_id = fields.Many2one('res.partner', 
        required=True,
        index=True,
        help='Contract holder'
    )

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

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

    # Financial
    provision_cost = fields.Monetary(
        currency_field='currency_id',
        help='Expected cost of service provision (COGS)'
    )
    currency_id = fields.Many2one('res.currency')

    # ABS Integration
    abs_serviceplan_ref = fields.Char(
        string='ABS ServicePlan ID',
        help='Reference returned by ABS after sync'
    )

    # Tracking
    service_event_ids = fields.One2many('abs.contract.event', 'contract_id')

6.2. Why Store physical_lot_id Directly?

Design Decision: Store serial directly on abs.contract, not computed from SO.

Rationale: 1. Query stability: SO line → stock.move → stock.move.line path can be modified 2. Snapshot semantics: Contract records the serial AT TIME of creation 3. Performance: Direct indexed lookup vs. multi-table join 4. Audit trail: Even if SO is modified, contract retains original binding


7. Contract Creation Logic

7.1. Factory Method

class ABSContract(models.Model):
    _name = 'abs.contract'

    @api.model
    def create_from_so_line(self, so_line, lot_id):
        """Create contract from SO line with specified serial."""
        product = so_line.product_id
        order = so_line.order_id

        # Calculate dates
        start_date = fields.Date.today()
        duration = product.service_duration_months or 12
        end_date = start_date + relativedelta(months=duration)

        contract = self.create({
            'so_line_id': so_line.id,
            'physical_lot_id': lot_id.id,
            'service_product_id': product.id,
            'partner_id': order.partner_id.id,
            'start_date': start_date,
            'end_date': end_date,
            'provision_cost': product.standard_price,
            'currency_id': order.currency_id.id,
            'state': 'active'
        })

        # Sync to ABS
        contract._sync_to_abs('create')

        return contract

7.2. Trigger Points

SO Type Trigger Event Serial Source
Bundle SO Delivery completion (stock.picking._action_done) so_line.fulfilled_lot_id
Service-only SO Order confirmation (sale.order.action_confirm) sale.order.target_lot_id (computed from source_so_id)

7.3. Field Mapping: SO → abs.contract

Source Target Notes
so_line.id so_line_id Reference to originating line
Physical line's fulfilled_lot_id OR order.target_lot_id physical_lot_id Depends on SO type
so_line.product_id service_product_id Service type
order.partner_id partner_id Contract holder
order.date_order start_date Activation date
Calculated end_date start_date + duration
product.standard_price provision_cost Expected COGS
Generated contract_number Sequence: SVC-2024-001234

7.4. Service-Only SO: Temporal Validation

Before contract creation, service-only SO validates:

# Days since original purchase
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 new product.')

    # 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.'
        )

    # Prerequisite check
    if product.requires_prior_service_id:
        prior = 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:
            raise ValidationError(
                f'"{product.name}" requires prior "{product.requires_prior_service_id.name}".'
            )

8. ABS Sync Protocol

8.1. Creation Event

Trigger: abs.contract created with state active

Payload:

{
  "event": "serviceplan.create",
  "contract_ref": "SO12345",
  "abs_contract_id": "SVC-2024-001234",
  "asset_ref": "E3Pro-67890",
  "customer_ref": "RES-00234",
  "service_type": "E3Pro-Warranty",
  "start_date": "2024-05-15",
  "end_date": "2027-05-15",
  "provision_cost": 500.00,
  "transferable": false,
  "metadata": {
    "so_line_id": 12345,
    "service_product_id": 123,
    "physical_product_id": 456
  }
}

ABS Response:

{
  "serviceplan_id": "SP-789012",
  "status": "provisioned",
  "operational_state": "active"
}

Odoo stores serviceplan_id in abs.contract.abs_serviceplan_ref.

8.2. Termination Event

Trigger: abs.contract.state changes to fulfilled, expired, or cancelled

Payload:

{
  "event": "serviceplan.terminate",
  "abs_contract_id": "SVC-2024-001234",
  "serviceplan_id": "SP-789012",
  "termination_reason": "expired",
  "termination_date": "2027-05-15"
}

8.3. Amendment Event (Asset Replacement)

Scenario: Faulty asset replaced with new serial

Odoo Actions: 1. Cancel old contract (state = 'cancelled') 2. Create new contract with new serial 3. Emit termination for old ServicePlan 4. Emit creation for new ServicePlan

Payload sequence:

[
  {
    "event": "serviceplan.terminate",
    "abs_contract_id": "SVC-001234",
    "termination_reason": "replaced"
  },
  {
    "event": "serviceplan.create",
    "abs_contract_id": "SVC-001567",
    "asset_ref": "E3Pro-99999",
    "replaces": "SVC-001234"
  }
]


9. SO Cancellation → Contract Cancellation

Policy: When SO is cancelled, all related abs.contract records are cancelled.

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

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

        for order in self:
            # Find all contracts from this SO
            contracts = self.env['abs.contract'].search([
                ('so_line_id.order_id', '=', order.id),
                ('state', 'not in', ['cancelled', 'fulfilled'])
            ])

            for contract in contracts:
                contract.state = 'cancelled'
                contract._sync_to_abs('terminate')

        return res

10. Liability Queries

10.1. Total Service Liability

SELECT 
    service_product_id,
    COUNT(*) as contract_count,
    SUM(provision_cost) as total_liability
FROM abs_contract
WHERE state = 'active'
GROUP BY service_product_id;

10.2. Contracts by Serial

SELECT 
    c.contract_number,
    p.name as service_type,
    c.start_date,
    c.end_date,
    c.state
FROM abs_contract c
JOIN product_product p ON c.service_product_id = p.id
WHERE c.physical_lot_id = %s
ORDER BY c.start_date DESC;

10.3. Customer's Active Contracts

SELECT 
    c.contract_number,
    l.name as serial,
    p.name as service_type,
    c.end_date
FROM abs_contract c
JOIN stock_lot l ON c.physical_lot_id = l.id
JOIN product_product p ON c.service_product_id = p.id
WHERE c.partner_id = %s
  AND c.state = 'active'
ORDER BY c.end_date;

11. B2C Validation Rules (Ownership)

11.1. Service-Only SO: Owner Check via source_so_id

@api.constrains('source_so_id', 'partner_id')
def _check_owner_can_purchase(self):
    """B2C: Only asset owner can purchase additional services."""
    if not self.is_service_only:
        return

    if not self.source_so_id:
        raise ValidationError(
            'Service-only orders must reference the Original Purchase Order.'
        )

    # Ownership enforced by FK
    if self.partner_id != self.source_so_id.partner_id:
        raise ValidationError(
            f'Service-only orders must be for the same customer.\n\n'
            f'Original: {self.source_so_id.partner_id.name}\n'
            f'Current: {self.partner_id.name}'
        )

11.2. Multiple Contracts Allowed

Policy: Same serial CAN have multiple contracts of the same type.

Example: Customer purchases 1-year warranty, then purchases another 1-year warranty extension.

Both contracts exist in abs.contract with different validity periods. This is NOT an error.


12. Summary: What Changed from Previous Design

Aspect OLD (Incorrect) NEW (Validated)
Serial field on service line service_lot_id NONE - serial comes from physical line
Serial lookup path Direct lot_id on SO line fulfilled_lot_id computed from stock.move.line
Service-only SO reference target_lot_id only source_so_id (FK to original SO)
target_lot_id User 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, per-product rules
Contract creation trigger On SO confirmation Bundle: after delivery; Service-only: on confirmation
abs.contract nature Transaction record Computed state (like stock.quant)
Ownership validation Not specified B2C: Only owner can purchase

13. Deferred for Later

The following scenarios are NOT covered in this B2C model:

  1. B2B / Fleet scenarios - Third-party purchases for managed fleet
  2. Ownership transfer - When asset is sold privately
  3. Voucher/gift system - Purchasing services for someone else's asset
  4. Third-party authorization - Service center claiming on behalf of customer

These will be addressed in a future B2B extension.