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.partnermay exist with no SA and no actor (Level 1 only). - Once SA-governed, an
ov.sa_customer_assignmentrow 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_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 — untouched by governance- 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 42res.partnercreated with all contact fields — no governance columns on this record- Portal
res.usersauto-created for Marie (login = email, random password) - Invitation email sent to marie@client.com
- MQTT
customer.createdevent published - V3 Layer 2 write —
ov.sa_customer_assignmentcreated:account_id = 42 partner_id = Marie.id state = active date_from = now assigned_by_id = Jean.partner_id - V3 Layer 3 write —
ov.actor_sa_customer_assignmentcreated: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¶
- V3 visibility confirms Alice (
staff,sa_wide) can see contact 101 - Kwame's
abs.employee.partner_idresolved - Active SA assignment found:
ov.sa_customer_assignment id=1 - Jean's active actor row found:
ov.actor_sa_customer_assignment id=1 - Jean's actor row deactivated:
state=inactive,date_to=now - 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_assignmentrow (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)¶
- 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}) - Jean's actor rows are deactivated — SA assignments remain active and unbroken.
- Customers Jean was handling now appear in
assigned_plus_unassignedfor 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:
- V3 visibility guard confirms Alice can see contact 101
- Linked portal
res.usersarchived (active=False) first res.partner.write({'active': False})- All active actor rows deactivated — cascade from
ov.sa_customer_assignment.expire() - All active SA assignments expired —
state=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.partnerknows 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_idpoints 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_idfield — 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.