Skip to content

Customer Lifecycle — End-to-End Documentation

Module version: 18.0.1.3.0 Governance layers: V3 (association models — two-table design) Last updated: 2026-04-30


ABS Platform Docs

  • ABS Docs Flow — Full battery-swap subscription lifecycle: contract → deposit → battery issuance → daily swaps → renewal → suspension → grace period → termination. Covers both FSM machines (Payment + Service), every state, every transition, and the three possible endings.

  • ABS Asset Flow — How physical assets (batteries, fleets, swap stations) enter the system, how ARM and ABS divide responsibility, how customer plans restrict access to specific stations and battery types, the W1–W4 asset resolution flow per swap, IoT integration scenarios, and the complete asset lifecycle from registration to write-off.


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 via the V3 two-table association design.

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
                           Customer is SA-visible but unassigned
                           (NO actor_id on this row — ADR-0003 §5)
                           ↓ only when an agent is designated
Level 3 — ACTOR ASSIGNED   ov.actor_sa_customer_assignment created
                           assignment_id → Level 2 row
                           actor_id = agent's res.partner
  • 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+).
  • The SA row never carries actor_id — actor assignment is a separate child row.
  • A customer can have an SA row but no actor row — unassigned but SA-visible.
  • Governance is optional. Actor assignment is optional inside governance.

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 — untouched by governance
  • 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. res.partner created with all contact fields — no governance columns on this record
  4. Portal res.users auto-created for Marie (login = email, random password)
  5. Invitation email sent to marie@client.com
  6. MQTT customer.created event published
  7. V3 Layer 2 writeov.sa_customer_assignment created:
    account_id     = 42
    partner_id     = Marie.id
    state          = active
    date_from      = now
    assigned_by_id = Jean.partner_id
    
  8. V3 Layer 3 writeov.actor_sa_customer_assignment created:
    assignment_id  = (id of row above)
    actor_id       = Jean.partner_id  (res.partner id=88)
    is_primary     = True             (first actor → primary)
    state          = active
    date_from      = now
    

Both writes are performed atomically by v3_write_assignment(). Failure is logged at ERROR level; the contact is still returned successfully but a governance inconsistency is recorded.

Data state after creation

res.partner  id=101  "Marie Dupont"
  (no governance columns — ADR-0003 §10)

ov.sa_customer_assignment  id=1
  account_id     = 42
  partner_id     = 101
  state          = active
  date_from      = 2026-04-30 10:00
  date_to        = NULL
  assigned_by_id = 88

ov.actor_sa_customer_assignment  id=1
  assignment_id  = 1
  actor_id       = 88  (Jean's res.partner)
  is_primary     = True
  state          = active
  date_from      = 2026-04-30 10:00

Stage 2 — Customer Visibility

List all customers (agent view)

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

Step 1: Find all active SA assignments for SA 42:

sa_rows = env['ov.sa_customer_assignment'].sudo().search([
    ('account_id', '=', 42),
    ('state',      '=', 'active'),
])

Step 2: Apply scope policy filter on the actor child table:

Policy Actor query
sa_wide No actor filter — all SA rows returned
assigned_plus_unassigned Actor rows where actor_id = Jean plus SA rows with no active actor row
assigned_only Only SA rows that have an actor row for Jean
# assigned_plus_unassigned example
Actor = env['ov.actor_sa_customer_assignment'].sudo()
my_assignment_ids = Actor.search([
    ('assignment_id', 'in', sa_rows.ids),
    ('actor_id', '=', jean_partner_id),
    ('state', '=', 'active'),
]).mapped('assignment_id').ids

unassigned_ids = [
    r.id for r in sa_rows
    if not Actor.search([('assignment_id', '=', r.id), ('state', '=', 'active')], limit=1)
]

visible_partner_ids = env['ov.sa_customer_assignment'].browse(
    list(set(my_assignment_ids) | set(unassigned_ids))
).mapped('partner_id').ids

Visibility policies

Policy What agent sees Default for
sa_wide All customers with an active SA assignment staff role
assigned_plus_unassigned My actor rows + SA rows with no actor yet agent role
assigned_only Only SA rows that have my actor row 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 by checking the assignment table and actor child table — then applies field updates directly to res.partner. No governance records are modified on a plain update.


Stage 4 — Actor Reassignment (V3-Canonical)

Reassign Marie from Jean to Kwame (operated by Alice, the SA manager):

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

{
  "employee_id": <kwame_employee_id>
}

What happens inside

  1. V3 visibility confirms Alice (staff, sa_wide) can see contact 101
  2. Kwame's abs.employee.partner_id resolved
  3. Active SA assignment found: ov.sa_customer_assignment id=1
  4. Jean's active actor row found: ov.actor_sa_customer_assignment id=1
  5. Jean's actor row deactivated: state=inactive, date_to=now
  6. New actor row created for Kwame:
    assignment_id  = 1   (SA assignment row unchanged)
    actor_id       = Kwame.partner_id
    is_primary     = True
    state          = active
    date_from      = now
    

The ov.sa_customer_assignment row (id=1) is never touched during actor reassignment. The SA-level claim on the customer is a separate fact from who is handling it.

Data state after reassignment

ov.sa_customer_assignment  id=1       ← unchanged
  account_id     = 42
  partner_id     = 101
  state          = active
  date_from      = 2026-04-30 10:00
  date_to        = NULL

ov.actor_sa_customer_assignment  id=1   ← Jean — closed
  assignment_id  = 1
  actor_id       = 88  (Jean)
  is_primary     = True
  state          = inactive
  date_to        = 2026-04-30 14:30

ov.actor_sa_customer_assignment  id=2   ← Kwame — current
  assignment_id  = 1
  actor_id       = 92  (Kwame)
  is_primary     = True
  state          = active
  date_from      = 2026-04-30 14:30
  date_to        = NULL
  assigned_by_id = Alice.partner_id     ← audit trail

Full actor history is preserved. The SA assignment (id=1) has a continuous unbroken claim — no visibility gap during the handover.


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

What happens inside (V3-canonical)

  1. Find all active SA assignments for SA 42 where Jean has an actor row:
    parent_ids = env['ov.sa_customer_assignment'].sudo().search([
        ('account_id', '=', 42), ('state', '=', 'active')
    ]).ids
    actor_rows = env['ov.actor_sa_customer_assignment'].sudo().search([
        ('assignment_id', 'in', parent_ids),
        ('actor_id', '=', Jean.partner_id),
        ('state', '=', 'active'),
    ])
    actor_rows.write({'state': 'inactive', 'date_to': now})
    
  2. Jean's actor rows are deactivated — SA assignments remain active and unbroken.
  3. Customers Jean was handling now appear in assigned_plus_unassigned for all agents (no actor row → treated as unassigned).

Data state after revocation:

ov.sa_customer_assignment  id=1
  state  = active          ← unchanged — customer not lost from SA

ov.actor_sa_customer_assignment  id=1
  actor_id = 88 (Jean)
  state    = inactive      ← deactivated at revocation
  date_to  = 2026-04-30 16:00

ov.actor_sa_customer_assignment  id=2
  actor_id = 92 (Kwame)
  state    = active        ← other actors unaffected

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

Membership revocation deactivates actor rows (Layer 3) only. The SA assignment (Layer 2) is never touched. The customer remains reachable in the SA's unassigned pool.


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. All active actor rows deactivated — cascade from ov.sa_customer_assignment.expire()
  5. All active SA assignments expiredstate=expired, date_to=now

Data state after archival:

res.partner  id=101
  active = False

ov.sa_customer_assignment  id=1
  state   = expired
  date_to = 2026-04-30 17:00

ov.actor_sa_customer_assignment  id=2  (Kwame)
  state   = inactive               ← cascade-inactivated by expire()
  date_to = 2026-04-30 17:00

Marie no longer appears in GET /api/contacts. Both the SA assignment history and actor history rows are permanently preserved for audit.


The Foreign Key Between the Two Association Tables

The actor table points up to the SA table via assignment_id. That is the only connection between them.

ov.sa_customer_assignment          ov.actor_sa_customer_assignment
──────────────────────────         ───────────────────────────────────────
id  ◄──────────────────────────── assignment_id   (FK, required, ondelete=cascade)
account_id                         actor_id
partner_id                         is_primary
state                              state
date_from                          date_from
date_to                            date_to
assigned_by_id                     assigned_by_id

Direction is strictly one-way

res.partner          ← no FK into any association table
      │
      │ partner_id (FK)
      ▼
ov.sa_customer_assignment    ← no FK pointing into the actor table
      │
      │ id (PK)
      ▼
ov.actor_sa_customer_assignment
      assignment_id (FK → ov.sa_customer_assignment.id)
  • res.partner knows nothing about SAs or actors
  • The SA table knows which customer it governs (partner_id) and which SA governs it (account_id) — it does not know which actors exist
  • The actor table holds the only cross-layer FK: assignment_id points to its parent SA row

ondelete='cascade' on assignment_id

If an SA assignment row is hard-deleted, all actor rows pointing to it are automatically deleted by the database. In practice this almost never fires because SA assignments are expired (state change), not hard-deleted. The normal lifecycle path uses expire(), which explicitly inactivates child actor rows before closing the parent.


Constraint: One Active Assignment Per Customer Per SA

Uniqueness is enforced by a partial unique index:

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

A Python @api.constrains check fires at the ORM level before the index, providing a clean ValidationError.


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, state, date_from, create_date, write_date)
SELECT
    rp.x_sa_id, rp.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'
  );

Actor rows for backfilled records must be created separately via the governance API if needed.


Data Model Reference

ov.sa_customer_assignment (Layer 2 — SA Scope)

Field Type Description
account_id Many2one ov.serviced_account The SA that owns this customer
partner_id Many2one res.partner The customer
state Selection active/expired Lifecycle state
date_from Datetime When the SA claimed the customer
date_to Datetime When the SA released it (null if active)
assigned_by_id Many2one res.partner Audit: who performed the governance action of creating this SA claim

No actor_id field — ADR-0003 §5. Actor information lives exclusively in the Layer 3 table.

Why assigned_by_id is on the SA table — and why it is not actor_id

assigned_by_id is a retrospective audit stamp, not a governance field. It records who performed the administrative action of creating the SA claim — past tense, one time, immutable after creation.

actor_id (in Layer 3) records who is currently responsible for the object — present tense, ongoing, changes over time.

The distinction:

assigned_by_id (Layer 2) actor_id (Layer 3)
Question answered Who made the governance decision? Who handles this object right now?
Tense Past — recorded once at creation Present — changes when actors are reassigned
Purpose Audit trail Operational responsibility
ADR §5 — SA metadata §6 — actor-scope layer

It cannot be replaced by Odoo's built-in create_uid because all governance writes use sudo(), which always sets create_uid to the system administrator, losing the identity of the real human who initiated the action. assigned_by_id preserves that business-level audit context.

Constraint: Partial unique index on (partner_id, account_id) WHERE state='active'.

Methods:

Method What it does
expire() Cascade-inactivates all child actor rows in ov.actor_sa_customer_assignment, then sets state=expired, date_to=now.

ov.actor_sa_customer_assignment (Layer 3 — Actor Scope)

Field Type Description
assignment_id Many2one ov.sa_customer_assignment FK to parent SA assignment — the only link between Layer 2 and Layer 3
actor_id Many2one res.partner The agent handling this customer within the SA
is_primary Boolean True = primary responsible person
state Selection active/inactive Whether this actor is currently active
date_from Datetime When this actor was assigned
date_to Datetime When this actor was removed
assigned_by_id Many2one res.partner Audit: who performed the action of assigning this actor

Constraint: UNIQUE(assignment_id, actor_id) — an actor appears once per SA-customer assignment.

Methods:

Method What it does
deactivate() Sets state=inactive, date_to=now. Actor loses visibility of the customer.

API Surface Summary

Method Endpoint Auth Description
POST /api/service-accounts API key Create branch SA
POST /api/service-accounts/<id>/members/enroll JWT Enroll agent
GET /api/me/service-accounts JWT My SAs + role
POST /api/contacts JWT (SA-governed) / API key (plain) Create customer
GET /api/contacts JWT List SA-governed customers
GET /api/contacts/<id> JWT Get single customer
PUT /api/contacts/<id> JWT Update customer
DELETE /api/contacts/<id> JWT Archive customer + expire assignments
POST /api/contacts/<id>/assign JWT / API key Reassign actor (deactivate old, create new)
DELETE /api/service-accounts/<id>/members/<mid> JWT Revoke membership + deactivate actor rows
GET /api/governance/customer/<id>/actors JWT List active actors on customer assignment
POST /api/governance/customer/<id>/actors JWT Add actor to customer assignment
DELETE /api/governance/customer/<id>/actors/<actor_id> JWT Remove actor from customer assignment

Visibility Policy Reference

Policies apply to any SA-scoped list endpoint. Resolved from ov.membership.scope_policy (override) or role default.

Policy How actor table is queried Default for
sa_wide All active SA assignments — no actor filter staff role
assigned_plus_unassigned Actor rows where actor_id = me OR SA rows with no active actor child agent role
assigned_only Only SA rows that have an active actor row for me Manual override

Three-Layer Summary

Every request:
  JWT → resolve_sa_context() → ov.membership lookup (V1 always)

GET /api/contacts:
  → SA assignments for account_id  (ov.sa_customer_assignment)
  → Actor filter on child table    (ov.actor_sa_customer_assignment)

POST /api/contacts (JWT):
  → res.partner.create()                        ← Layer 1 (no governance cols)
  → ov.sa_customer_assignment.create()          ← Layer 2 (SA claim)
  → ov.actor_sa_customer_assignment.create()    ← Layer 3 (actor link)

POST /api/contacts/<id>/assign:
  → ov.actor_sa_customer_assignment old.write(state=inactive)  ← Layer 3 only
  → ov.actor_sa_customer_assignment.create(new actor)          ← Layer 3 only
  (ov.sa_customer_assignment is NEVER modified during actor reassignment)

DELETE /api/contacts/<id>:
  → res.partner.write(active=False)             ← Layer 1
  → ov.sa_customer_assignment.expire()          ← Layer 2 → cascades to Layer 3

Membership revoked:
  → ov.actor_sa_customer_assignment.write(inactive)  ← Layer 3 only
  (ov.sa_customer_assignment stays active — customer not lost from SA)

Odoo Objects Created — Internal Flow

Odoo Object Model Created by Notes
Customer res.partner POST /api/contacts Core identity record — no governance columns
SA assignment ov.sa_customer_assignment POST /api/contacts (dual-write) Layer 2 — links customer to SA
Actor assignment ov.actor_sa_customer_assignment POST /api/contacts (dual-write) Layer 3 — links agent to SA assignment
Sale Order sale.order POST /api/subscription/purchase Subscription purchase order
Invoice account.move Auto-generated on order confirmation Linked to sale order
Payment record account.payment Recorded when payment collected Linked to invoice
Subscription abs.subscription POST /api/subscription/purchase Tracks plan state, quota
MQTT CREATE Odoo after subscription created Fires CREATE_SERVICE_PLAN_FROM_TEMPLATE to ABS
MQTT SYNC Odoo after payment confirmed Fires SYNC_ODOO_SUBSCRIPTION to ABS

For the external partner (Partners Swap Applet) flow, only res.partner and ov.sa_customer_assignment are created in Odoo. No orders, invoices, payments, or subscriptions. See external-client-swap.md for the full comparison.


Association Tables

Full schema, visibility rules, all 26 governed domain objects, governance API, dual-write pattern, scenarios, and query reference are documented in the dedicated Association Tables page.