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:
- B2B / Fleet scenarios - Third-party purchases for managed fleet
- Ownership transfer - When asset is sold privately
- Voucher/gift system - Purchasing services for someone else's asset
- Third-party authorization - Service center claiming on behalf of customer
These will be addressed in a future B2B extension.
Related Documentation¶
- Service Bundles Intent - Bundle architecture overview
- SO Contract Structure - SO validation and extensions
- Odoo Lot/Serial Numbers - Physical asset identity