Skip to content

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.partner may exist with no SA and no actor (Level 1 only).
  • Once SA-governed, an ov.sa_customer_assignment row exists (Level 2+).
  • Inside that row, actor_id may 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_manager at creation.
  • Branch containment: a child SA's company_id must 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
Both require 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.partner created — no x_sa_id, no x_actor_id
  • No ov.sa_customer_assignment created
  • 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

  1. JWT validated → Jean's abs.employee resolved
  2. X-SA-ID: 42 validated → Jean is an active member of SA 42
  3. V2 compatibility stamps applied to contact_vals:
  4. x_sa_id = 42
  5. x_actor_id = Jean.partner_id
  6. x_assigned_employee_id = Jean (abs.employee)
  7. res.partner created with all contact fields
  8. Portal res.users auto-created for Marie (login = email, random password)
  9. Invitation email sent to marie@client.com
  10. MQTT customer.created event published
  11. V3 primary governance writeov.sa_customer_assignment created (Level 2→3):
    account_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_id
    
    Failure at this step is logged at ERROR level. The contact is still returned successfully but a governance inconsistency is recorded. Run POST /api/migration/backfill-customer-assignments to 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)

  1. V3 visibility confirms Alice (staff, sa_wide) can see contact 101
  2. Kwame's abs.employee.partner_id resolved (auto-created if missing)
  3. Active assignment record found: ov.sa_customer_assignment id=1
  4. assignment.reassign(new_actor=Kwame.partner_id, assigned_by=Alice.partner_id):
  5. Closes old row: id=1state=expired, date_to=now
  6. Opens new row: id=2state=active, actor_id=Kwame.partner_id, date_from=now
  7. V2 compat stamps updated on res.partner:
  8. x_actor_id = Kwame.partner_id
  9. x_assigned_employee_id = Kwame (abs.employee)
  10. 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 than expire(). 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):

  1. V3 visibility guard confirms Alice can see contact 101
  2. Linked portal res.users archived (active=False) first
  3. res.partner.write({'active': False})
  4. V3: all active assignments expiredactive_assignments.expire()
  5. 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