Skip to content

Customer Lifecycle — End-to-End Documentation

Module version: 18.0.1.1.1

Governance layers: V1 (foundation) · V2 (stamps) · V3 (association models)

Last updated: 2026-04-23


Overview

A customer (res.partner) in this system is not just an Odoo contact. It is a governed entity — owned by a Service Account (SA), assigned to an agent, traceable through its full assignment history. This document describes the complete lifecycle from system bootstrap through customer creation, assignment, reassignment, agent revocation, and archival.


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   parent_id=NULL
  ├── [Company A Seed SA]     is_seed=True          source_company_id=Company A
  │     └── [Branch SA]       normal SA             parent_id=Seed SA
  └── [Company B Seed SA]     is_seed=True          source_company_id=Company B
        └── [Branch SA]
SA Type parent_id source_company_id is_global_root is_seed Created by
Global Root NULL NULL True False Migration 18.0.1.1.1
Company Seed SA Global Root SET False True Migration 18.0.1.0.2
Branch SA Any SA NULL False False API / admin

Key Rules

  • There is exactly one global root SA in the entire system.
  • There is exactly one seed SA per res.company.
  • Every branch SA must have a parent and a sa_manager at creation.
  • sa_manager is exempt for the global root and seed SAs.
  • Branch containment: a child SA's company_id must match its parent's — except seed SAs whose parent is the global root (cross-company is intentional at that level).

Stage 0 — SA and Agent Setup

0a. Global Root SA (auto-created)

Created by migration 18.0.1.1.1. No API call needed. One per Odoo installation.

ov.serviced_account
  name              = "OV Global Root"
  is_global_root    = True
  parent_id         = NULL
  source_company_id = NULL
  company_id        = main company (id=1)
  state             = active

0b. Company Seed SA (auto-created)

Created by migration 18.0.1.0.2, one per res.company. Re-parented under global root by migration 18.0.1.1.1.

ov.serviced_account
  name              = <company name>
  is_seed           = True
  parent_id         = global root SA
  source_company_id = <res.company id>
  company_id        = <res.company id>
  state             = active

0c. 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

0d. 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)
  • 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

Stage 1 — Customer Creation

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 is validated → Jean's abs.employee resolved
  2. X-SA-ID: 42 validated → Jean is an active member of SA 42
  3. contact_vals stamped (V2 compat):
  4. x_sa_id = 42
  5. x_actor_id = Jean.partner_id
  6. res.partner created with all contact fields
  7. Portal res.users auto-created for Marie (login = email, random password)
  8. Invitation email sent to marie@client.com
  9. V3 dual-writeov.sa_customer_assignment created:
account_id    = 42
partner_id    = Marie.id
actor_id      = Jean.partner_id
state         = active
date_from     = now
assigned_by   = Jean.partner_id
  1. customer.created MQTT event published

Data state after creation

res.partner  id=101  "Marie Dupont"
  x_sa_id         = 42          ← V2 compat (kept)
  x_actor_id      = Jean        ← V2 compat (kept)
  assignment_ids  → [id=1]      ← V3 inverse relation

ov.sa_customer_assignment  id=1
  account_id  = 42
  partner_id  = 101
  actor_id    = Jean.partner_id
  state       = active
  date_from   = 2026-04-23 10:00
  assigned_by = Jean.partner_id

Stage 2 — Customer Visibility

List all customers (agent view)

GET /api/contacts
Authorization: Bearer <jean_jwt>
X-SA-ID: 42

V3 visibility resolution:

  1. resolve_partner_sa_visibility_domain(portal_user) called
  2. Jean's role = agent → policy = assigned_plus_unassigned
  3. build_partner_sa_visibility_domain() queries assignment table:
SELECT partner_id
FROM ov_sa_customer_assignment
WHERE account_id = 42
  AND state = 'active'
  AND (actor_id = Jean.partner_id OR actor_id IS NULL)
  1. Returns [('id', 'in', [101, ...])]
  2. Domain applied to res.partner.search_read()
Role Policy Sees
agent assigned_plus_unassigned Own customers + unassigned
staff sa_wide All customers in SA 42
Any assigned_only (override) Only own customers

Fetch single customer

GET /api/contacts/101
Authorization: Bearer <jean_jwt>
X-SA-ID: 42

Same V3 domain applied as [('id', '=', 101)] + vis_domain. If 101 is not in Jean's allowed set → 404 Contact not found.


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 — then applies the field updates.


Stage 4 — Actor Reassignment

Reassign Marie from Jean to Kwame (employee-level operational reassignment):

POST /api/contacts/101/assign
Authorization: Bearer <alice_jwt>
X-SA-ID: 42

{
  "employee_id": <kwame_employee_id>
}

What happens:

  1. V3 visibility confirms Alice (staff, sa_wide) can see contact 101
  2. contact.x_assigned_employee_id = Kwame
  3. Native salesperson (user_id) synced if Kwame has an Odoo user

Note

Full V3 actor reassignment (updating ov.sa_customer_assignment.actor_id) is a Phase 2 item. The current endpoint handles operational employee assignment.


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})
crm.lead.search([...]).write({x_actor_id: False})
# ... all _GOVERNED_MODELS

V3 side-effect (new)

# Clears actor_id on Jean's active assignment records in SA 42
ov.sa_customer_assignment.search([
    account_id=42, actor_id=Jean, state='active'
]).clear_actor()
# → actor_id = NULL, state stays 'active'
# Customer stays in SA 42 as unassigned, not lost

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=1
  account_id  = 42
  partner_id  = 101
  actor_id    = NULL       ← cleared, now unassigned
  state       = active     ← still active, customer not lost

Marie now appears in any agent's assigned_plus_unassigned list until reassigned.


Stage 6 — Customer Archival

DELETE /api/contacts/101
Authorization: Bearer <alice_jwt>
X-SA-ID: 42

What happens:

  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. ov.sa_customer_assignment record preserved (state='active') — audit history intact

Marie no longer appears in GET /api/contacts (filtered out by active=True domain) but the assignment record remains for historical audit.


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
  )
ON CONFLICT (partner_id, account_id) DO NOTHING

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 The assigned agent (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

SQL constraint: UNIQUE(partner_id, account_id) — one assignment per customer per SA.

Compatibility stamps on res.partner (V2, transitional)

Field Description Status
x_sa_id SA context stamp Kept as compatibility layer
x_actor_id Actor stamp Kept as compatibility layer
assignment_ids V3 inverse One2many V3 canonical read path

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 V2 stamp + V3 dual-write Create customer
GET /api/contacts JWT V3 List customers (SA-scoped)
GET /api/contacts/<id> JWT V3 Get single customer
PUT /api/contacts/<id> JWT V3 Update customer
DELETE /api/contacts/<id> JWT V3 Archive customer
POST /api/contacts/<id>/assign JWT / API key V2 Reassign employee
DELETE /api/service-accounts/<id>/members/<mid> JWT V1 + V2 clear + V3 clear Revoke membership
POST /api/migration/backfill-customer-assignments API key V3 Backfill assignments

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 write (kept)
  → create ov.sa_customer_assignment           ← V3 canonical write

Membership revoked:
  → clear x_actor_id on stamped records        ← V2 compat clear
  → clear actor_id on assignment records       ← V3 clear

Roadmap — Next V3 Phases

Phase Object New model Controller
Phase 1 ✅ res.partner ov.sa_customer_assignment contacts.py
Phase 2 sale.order ov.sa_sale_order_assignment order_controller.py, session_controller.py
Phase 3 crm.lead ov.sa_lead_assignment crm.py
Phase 4 ov.asset ov.sa_asset_assignment asset_api.py
Phase 5 account.move ov.sa_invoice_assignment New (V3-first, no stamp)
Phase 6 helpdesk.ticket ov.sa_ticket_assignment New (V3-first, no stamp)
Future Retire stamps Remove x_sa_id/x_actor_id from res.partner After all consumers migrate