Customer Lifecycle — End-to-End Documentation¶
Module version: 18.0.1.1.2
Governance layers: V1 (foundation) · V2 (stamps) · V3 (association models)
Last updated: 2026-04-23
Overview¶
A res.partner is a plain Odoo contact by default. It only becomes a governed customer when it is brought under an SA's visibility scope. Governance and actor assignment are both optional — they apply only when an agent with SA context creates or claims the contact.
The three levels are strictly separate:
Level 1 — EXISTENCE res.partner exists in Odoo
No SA. No actor. Plain contact record.
(created by admin / API key with no JWT)
↓ only when SA context applies
Level 2 — SA GOVERNANCE ov.sa_customer_assignment created
account_id = SA42, actor_id = NULL
Customer is SA-visible but unassigned
↓ only when an agent is designated
Level 3 — ACTOR ASSIGNED ov.sa_customer_assignment.actor_id = agent
Customer has a dedicated responsible agent
- A
res.partnermay exist with no SA and no actor (Level 1 only). - Once SA-governed, an
ov.sa_customer_assignmentrow exists (Level 2+). - Inside that row,
actor_idmay be NULL — unassigned but SA-visible. - The stricter V3 rule is not "every customer must have an SA." It is: actor assignment is optional inside governance; governance itself is optional.
This document describes the full lifecycle for customers that enter SA governance — from system bootstrap through creation, reassignment, revocation, and archival.
All gaps identified in the V3 gap review have been resolved in this version:
| Gap | Resolution | Version |
|---|---|---|
UNIQUE(partner_id, account_id) blocked history |
Replaced with partial unique index WHERE state='active' |
18.0.1.1.2 |
| Reassignment was not V3-canonical | assign endpoint now expires old row + creates new row |
18.0.1.1.2 |
| V3 dual-write failure was silent | Now logged at ERROR level with recovery instructions | 18.0.1.1.2 |
| Archival did not expire assignments | DELETE /api/contacts/<id> now calls assignment.expire() |
18.0.1.1.2 |
SA Hierarchy — The Container Structure¶
Before any customer can exist under governance, the SA hierarchy must be in place.
OV Global Root SA ← is_global_root=True, company_id=NULL, parent_id=NULL
├── [Company A Root SA] ← is_root=True, source_company_id=Company A
│ └── [Branch SA] ← is_root=False, parent_id=Company A Root SA
│ └── Customers, orders, deliveries …
└── [Company B Root SA] ← is_root=True, source_company_id=Company B
└── [Branch SA] ← is_root=False, parent_id=Company B Root SA
| SA Type | parent_id |
source_company_id |
is_global_root |
company_id |
Created by |
|---|---|---|---|---|---|
| Global Root SA | NULL | NULL | True |
NULL (no company) |
Migration 18.0.1.1.5 |
| Company Root SA | Global Root SA | SET | False |
Company | Migration 18.0.1.0.2 |
| Branch SA | Any SA | NULL | False |
Same as parent | API / admin |
Key Rules¶
- There is exactly one Global Root SA in the entire system (
is_global_root = True). - There is exactly one company root SA per
res.company(auto-created by migration). - Every branch SA must have a parent and a
sa_managerat creation. - Branch containment: a child SA's
company_idmust match its parent's (except when the parent is the Global Root SA). - The Global Root SA has no
company_id— it is not tied to any tenant.
Verify the hierarchy via API¶
GET /api/system/global-root # Check global root SA and its admins
GET /api/system/sa-hierarchy # Full nested SA tree
GET /api/system/sa-hierarchy?flat=true # Flat list with depth field
X-API-KEY header.
Stage 0 — SA and Agent Setup¶
0a. Company Seed SA (auto-created)¶
Created automatically by migration 18.0.1.0.2 for each res.company. No API call needed.
ov.serviced_account
name = <company name>
source_company_id = <res.company id>
company_id = <res.company id>
parent_id = NULL
state = active
0b. Branch SA (API-created)¶
POST /api/service-accounts
X-API-KEY: <system key>
{
"name": "Togo Field Operations",
"parent_id": <seed_sa_id>,
"partner_id": <org_partner_id>,
"initial_admin_partner_id": <manager_partner_id>
}
Creates:
- ov.serviced_account (state='active', account_class='EXTC')
- ov.membership for the manager (role_code='staff', manager_member_id=NULL)
- SA.sa_manager → that membership
0c. Agent Enrollment¶
POST /api/service-accounts/<sa_id>/members/enroll
Authorization: Bearer <manager_jwt>
X-SA-ID: <sa_id>
{
"name": "Jean Kofi",
"email": "jean@example.com",
"role_code": "agent"
}
Creates:
- res.partner for Jean (if not existing)
- abs.employee (free-seat, no Odoo user seat) — with partner_id linked to the res.partner
- ov.membership (role_code='agent', membership_state='active')
Resulting state:
ov.serviced_account id=42 "Togo Field Operations"
sa_manager → ov.membership Alice staff
membership → ov.membership Jean agent
abs.employee id=7 Jean Kofi
partner_id → res.partner id=88 Jean Kofi
Stage 1 — Customer Creation¶
1a. Plain creation (Level 1 only — no governance)¶
When created via API key (admin/system call, no JWT):
POST /api/contacts
X-API-KEY: <key>
{ "name": "Marie Dupont", "email": "marie@client.com" }
res.partnercreated — nox_sa_id, nox_actor_id- No
ov.sa_customer_assignmentcreated - Marie exists as a plain Odoo contact, not under any SA
1b. SA-governed creation (Level 2+) — agent with JWT¶
When an agent creates a customer via JWT with SA context:
POST /api/contacts
Authorization: Bearer <jean_jwt>
X-SA-ID: 42
{
"name": "Marie Dupont",
"email": "marie@client.com",
"phone": "+228 90 000 001"
}
What happens inside¶
- JWT validated → Jean's
abs.employeeresolved X-SA-ID: 42validated → Jean is an active member of SA 42- V2 compatibility stamps applied to
contact_vals: x_sa_id = 42x_actor_id = Jean.partner_idx_assigned_employee_id = Jean (abs.employee)res.partnercreated with all contact fields- Portal
res.usersauto-created for Marie (login = email, random password) - Invitation email sent to marie@client.com
- MQTT
customer.createdevent published - V3 primary governance write —
ov.sa_customer_assignmentcreated (Level 2→3):Failure at this step is logged at ERROR level. The contact is still returned successfully but a governance inconsistency is recorded. Runaccount_id = 42 partner_id = Marie.id actor_id = Jean.partner_id (res.partner id=88) state = active date_from = now assigned_by_id = Jean.partner_idPOST /api/migration/backfill-customer-assignmentsto recover.
Data state after creation¶
res.partner id=101 "Marie Dupont"
x_sa_id = 42 ← V2 compat (transitional)
x_actor_id = 88 ← V2 compat (transitional)
x_assigned_employee_id = 7 (Jean)
assignment_ids → [id=1] ← V3 inverse relation
ov.sa_customer_assignment id=1
account_id = 42
partner_id = 101
actor_id = 88 (Jean's res.partner)
state = active
date_from = 2026-04-23 10:00
date_to = NULL
assigned_by_id = 88
Stage 2 — Customer Visibility¶
List all customers (agent view)¶
GET /api/contacts
Authorization: Bearer <jean_jwt>
X-SA-ID: 42
Resolved via resolve_partner_sa_visibility_domain() — reads only ov.sa_customer_assignment:
Assignment.search([
('account_id', '=', 42),
('state', '=', 'active'),
# policy-based actor filter:
# sa_wide → no further filter
# assigned_plus_unassigned → ('actor_id', 'in', [Jean.partner_id, False])
# assigned_only → ('actor_id', '=', Jean.partner_id)
])
→ [('id', 'in', partner_ids)]
The V2 x_sa_id stamp is not used for any read on res.partner — V3 is the sole read path.
Visibility policies¶
| Policy | Domain addition | Default for |
|---|---|---|
sa_wide |
None — all active in SA | staff role |
assigned_plus_unassigned |
actor_id IN (me, NULL) |
agent role |
assigned_only |
actor_id = me |
Manual override |
Stage 3 — Customer Update¶
PUT /api/contacts/101
Authorization: Bearer <jean_jwt>
X-SA-ID: 42
{
"phone": "+228 90 000 002",
"city": "Lomé"
}
V3 visibility guard runs first — confirms Jean can see contact 101 via the assignment table — then applies the field updates to res.partner.
Stage 4 — Actor Reassignment (V3-Canonical)¶
Reassign Marie from Jean to Kwame:
POST /api/contacts/101/assign
Authorization: Bearer <alice_jwt>
X-SA-ID: 42
{
"employee_id": <kwame_employee_id>
}
What happens inside (fully V3-canonical since 18.0.1.1.2)¶
- V3 visibility confirms Alice (staff,
sa_wide) can see contact 101 - Kwame's
abs.employee.partner_idresolved (auto-created if missing) - Active assignment record found:
ov.sa_customer_assignment id=1 assignment.reassign(new_actor=Kwame.partner_id, assigned_by=Alice.partner_id):- Closes old row:
id=1→state=expired,date_to=now - Opens new row:
id=2→state=active,actor_id=Kwame.partner_id,date_from=now - V2 compat stamps updated on
res.partner: x_actor_id = Kwame.partner_idx_assigned_employee_id = Kwame (abs.employee)user_id = Kwame.user_id(if set)
Data state after reassignment¶
ov.sa_customer_assignment id=1 ← closed (history)
partner_id = 101
actor_id = 88 (Jean)
state = expired
date_from = 2026-04-23 10:00
date_to = 2026-04-23 14:30 ← set by expire()
ov.sa_customer_assignment id=2 ← current active
partner_id = 101
actor_id = 92 (Kwame)
state = active
date_from = 2026-04-23 14:30
date_to = NULL
assigned_by_id = Alice.partner_id ← audit trail
res.partner id=101
x_actor_id = 92 ← V2 compat updated
x_assigned_employee_id = Kwame
The expired row (id=1) is permanently preserved. Full assignment history is queryable:
GET /api/contacts/101
→ response includes assignment_history: [
{ id: 1, actor: "Jean", state: "expired", from: "...", to: "..." },
{ id: 2, actor: "Kwame", state: "active", from: "...", to: null }
]
Edge case: no active V3 assignment found¶
If the contact was created before V3 (stamp-only, no assignment record), the endpoint creates a new active row directly and logs a warning. The backfill endpoint should be run to ensure all legacy contacts have assignment records.
Stage 5 — Agent Membership Revocation¶
Jean leaves. Alice revokes his membership.
DELETE /api/service-accounts/42/members/<jean_membership_id>
Authorization: Bearer <alice_jwt>
X-SA-ID: 42
OvMembership.write({'membership_state': 'revoked'}) triggers two side-effects:
V2 side-effect (legacy compat)¶
# Clears x_actor_id on all legacy-stamped records Jean owned in SA 42
res.partner.search([x_sa_id=42, x_actor_id=Jean]).write({x_actor_id: False})
sale.order.search([x_sa_id=42, x_actor_id=Jean]).write({x_actor_id: False})
# ... all _GOVERNED_MODELS
V3 side-effect¶
# Clears actor_id on Jean's active assignment records in SA 42
# Does NOT expire the assignment — customer stays in SA as unassigned, not lost
ov.sa_customer_assignment.search([
account_id=42, actor_id=Jean.partner_id, state='active'
]).clear_actor()
# → actor_id = NULL, state stays 'active', date_to stays NULL
Data state after revocation:
res.partner id=101 "Marie Dupont"
x_sa_id = 42 ← still stamped (customer stays in SA)
x_actor_id = NULL ← cleared
ov.sa_customer_assignment id=2
account_id = 42
partner_id = 101
actor_id = NULL ← cleared by clear_actor()
state = active ← still active, customer not lost
date_to = NULL
Marie now appears in any agent's assigned_plus_unassigned list until reassigned.
Note: Membership revocation uses
clear_actor()(keeps assignment active, removes actor) rather thanexpire(). This is intentional — the customer remains reachable in the SA's unassigned pool. Only explicit archival or reassignment changes the assignment state.
Stage 6 — Customer Archival¶
DELETE /api/contacts/101
Authorization: Bearer <alice_jwt>
X-SA-ID: 42
What happens (V3-correct since 18.0.1.1.2):
- V3 visibility guard confirms Alice can see contact 101
- Linked portal
res.usersarchived (active=False) first res.partner.write({'active': False})- V3: all active assignments expired —
active_assignments.expire() state → expired,date_to → now
Data state after archival:
res.partner id=101
active = False
ov.sa_customer_assignment id=2
state = expired ← closed at archival
date_to = 2026-04-24 09:00 ← set by expire()
Marie no longer appears in GET /api/contacts (filtered by active=True) and her assignment is expired. The history rows (id=1, id=2) are permanently preserved for audit.
Constraint: One Active Assignment Per Customer Per SA¶
Since version 18.0.1.1.2, uniqueness is enforced by a partial unique index:
-- Migration 18.0.1.1.2
CREATE UNIQUE INDEX ov_sa_customer_assignment_one_active_per_sa
ON ov_sa_customer_assignment (partner_id, account_id)
WHERE state = 'active';
This allows: - Any number of expired rows per customer per SA (full history) - Exactly one active row per customer per SA at any time
Attempting to create a second active assignment for the same customer/SA combination raises a ValidationError at the ORM level (Python constraint) before hitting the database index.
Backfill — Migrating Legacy Customers¶
For customers created before V3 (stamps but no assignment records):
Via Odoo module upgrade¶
Migration 18.0.1.1.0 runs automatically on odoo -u abs_connector:
INSERT INTO ov_sa_customer_assignment
(account_id, partner_id, actor_id, state, date_from, create_date, write_date)
SELECT
rp.x_sa_id, rp.id, rp.x_actor_id, 'active',
COALESCE(rp.create_date, NOW()), NOW(), NOW()
FROM res_partner rp
WHERE rp.x_sa_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM ov_sa_customer_assignment a
WHERE a.partner_id = rp.id AND a.account_id = rp.x_sa_id
AND a.state = 'active'
)
Via API (dev/test)¶
POST /api/migration/backfill-customer-assignments
X-API-KEY: <key>
{
"company_id": 1,
"dry_run": true
}
Returns a preview of what will be created before committing.
Data Model Reference¶
ov.sa_customer_assignment¶
| Field | Type | Description |
|---|---|---|
account_id |
Many2one ov.serviced_account |
The SA that owns this customer |
partner_id |
Many2one res.partner |
The customer |
actor_id |
Many2one res.partner |
Assigned agent's res.partner (nullable = unassigned) |
state |
Selection active/expired |
Lifecycle state |
date_from |
Datetime | When assignment became effective |
date_to |
Datetime | When assignment expired (null if active) |
assigned_by_id |
Many2one res.partner |
Who created/changed this assignment |
Constraint: Partial unique index on (partner_id, account_id) WHERE state='active' — exactly one active assignment per customer per SA. Unlimited expired rows allowed (history).
Helpers:
- expire() — sets state=expired, date_to=now
- clear_actor() — sets actor_id=NULL, keeps state active (used on membership revocation)
- reassign(new_actor_partner_id, assigned_by_partner_id) — expires self, returns new active row
Compatibility stamps on res.partner (V2, transitional)¶
| Field | Description | Status |
|---|---|---|
x_sa_id |
SA context stamp | Kept as compatibility layer — read only by non-V3-migrated controllers |
x_actor_id |
Actor stamp | Kept in sync with active assignment's actor_id |
x_assigned_employee_id |
abs.employee link | Operational assignment (free-seat employee) |
assignment_ids |
V3 inverse One2many | V3 canonical read path — use this |
API Surface Summary¶
| Method | Endpoint | Auth | Governance Layer | Description |
|---|---|---|---|---|
POST |
/api/service-accounts |
API key | V1 | Create branch SA |
POST |
/api/service-accounts/<id>/members/enroll |
JWT | V1 | Enroll agent |
GET |
/api/me/service-accounts |
JWT | V1 | My SAs + role |
POST |
/api/contacts |
JWT → Level 2+; API key → Level 1 | V2 stamp + V3 primary write (JWT only) | Create customer |
GET |
/api/contacts |
JWT | V3 only | List SA-governed customers (Level 2+) only |
GET |
/api/contacts/<id> |
JWT | V3 only | Get single customer |
PUT |
/api/contacts/<id> |
JWT | V3 only | Update customer |
DELETE |
/api/contacts/<id> |
JWT | V3 only + expire assignment | Archive customer |
POST |
/api/contacts/<id>/assign |
JWT / API key | V3-canonical + V2 compat sync | Reassign (close old row + open new) |
DELETE |
/api/service-accounts/<id>/members/<mid> |
JWT | V1 + V2 clear + V3 clear_actor | Revoke membership |
POST |
/api/migration/backfill-customer-assignments |
API key | V3 | Backfill assignment records |
Visibility Policy Reference¶
Policies apply to any SA-scoped list endpoint. Resolved from ov.membership.scope_policy (override) or role default.
| Policy | SQL equivalent | Who gets it by default |
|---|---|---|
sa_wide |
All active assignments in SA | staff role |
assigned_plus_unassigned |
actor_id = me OR actor_id IS NULL |
agent role |
assigned_only |
actor_id = me |
Manual override only |
V1 / V2 / V3 Coexistence¶
Every request:
JWT → resolve_sa_context() → ov.membership lookup (V1 always)
GET /api/contacts:
→ resolve_partner_sa_visibility_domain() ← V3 (reads ov.sa_customer_assignment)
GET /api/orders:
→ build_sa_visibility_domain() ← V2 (reads x_sa_id stamp, not yet V3)
POST /api/contacts:
→ stamp x_sa_id / x_actor_id ← V2 compat (kept for non-migrated consumers)
→ create ov.sa_customer_assignment ← V3 primary write (ERROR logged if this fails)
POST /api/contacts/<id>/assign:
→ ov.sa_customer_assignment.reassign() ← V3 canonical (expires old, creates new)
→ write x_actor_id / x_assigned_employee ← V2 compat sync
DELETE /api/contacts/<id>:
→ res.partner.write(active=False) ← Odoo archive
→ ov.sa_customer_assignment.expire() ← V3 lifecycle close
Membership revoked:
→ clear x_actor_id on stamped records ← V2 compat clear
→ clear actor_id on assignment records ← V3 clear_actor (assignment stays active, unassigned)
Roadmap — Next V3 Phases¶
| Phase | Object | New model | Controller | Status |
|---|---|---|---|---|
| Phase 1 ✅ | res.partner |
ov.sa_customer_assignment |
contacts.py |
Complete — 18.0.1.1.2 |
| Phase 2 ✅ | sale.order |
ov.sa_sale_order_assignment |
order_controller.py |
Complete — 18.0.1.1.3 |
| Phase 2 ✅ | stock.picking |
ov.sa_delivery_assignment |
stock_api.py |
Complete — 18.0.1.1.3 |
| Phase 4 ✅ | ov.asset |
ov.sa_asset_assignment |
asset_api.py |
Complete — 18.0.1.1.3 |
| Phase 5 ✅ | account.move |
ov.sa_invoice_assignment |
invoice_api.py |
Complete — 18.0.1.1.3 |
| Phase 5 ✅ | account.payment |
ov.sa_payment_assignment |
order_controller.py |
Complete — 18.0.1.1.3 |
| Phase 6 ✅ | helpdesk.ticket |
ov.sa_ticket_assignment |
tickets.py |
Complete — 18.0.1.1.3 |
| Future | Retire stamps | Remove x_sa_id/x_actor_id from all models |
After supervisor sign-off | Final cleanup |