Skip to content

External Client Swap Process

Scope: Battery swap workflow for external partners with their own Service Accounts
Protocol: Odoo REST API (customer creation only) + Partners Swap Applet (plan + swap operations)
Last updated: 2026-04-28
Companion docs: customer-lifecycle.md · operations-setup.md


Overview

An external partner is any organisation that operates their own battery swap station under an Omnivoltaic Service Account (SA). They manage their own customers inside their SA using Odoo — but plan creation, activation, and all swap operations happen through the Partners Swap Applet with no further Odoo dependency.

REGISTRATION (one time per customer)
  Step 1 — Odoo: create customer → get partner_id
  Step 2 — Applet: fire CREATE using partner_id as both customer_id and service_plan_id
  Step 3 — Applet: on echo, fire SYNC automatically
  → Customer is live and ready for swaps

EVERY SWAP (no Odoo)
  → Partner uses Partners Swap Applet only

Odoo is touched once per customer to create the contact and get the partner_id. Everything after that is the applet.


Odoo Objects Created — Internal vs External

These two tables show exactly which Odoo records are created in each path. The key difference: the external partner bypasses Odoo's entire financial layer. Revenue is tracked in PayAfrica and ABS, not in Odoo.

Internal Flow (Omnivoltaic staff — ovApp)

Odoo Object Model Created?
Customer res.partner Yes
SA assignment ov.sa_customer_assignment Yes
Sale Order sale.order Yes
Invoice account.move Yes
Payment record account.payment Yes
Subscription abs.subscription Yes
MQTT CREATE + SYNC Fired by Odoo automatically

External Flow (Partners Swap Applet)

Odoo Object Model Created?
Customer res.partner Yes — single POST /api/contacts call
SA assignment ov.sa_customer_assignment Yes — created automatically by POST /api/contacts
Sale Order sale.order No
Invoice account.move No
Payment record account.payment No
Subscription abs.subscription No
MQTT CREATE + SYNC Fired directly by the applet, not by Odoo

The partner's revenue, quota tracking, and payment history all live in ABS and PayAfrica. Odoo holds the customer identity only.


Prerequisites

The partner must have:

Item Description
SA membership Active membership in their SA with at least staff role
JWT token Obtained by logging into Odoo via /api/auth/login
SA ID The id of their SA, sent as X-SA-ID header on all Odoo API calls
Swap products registered in applet The partner registers their swap products once in the applet, providing the template_id string for each. Valid template_id strings are provided by Omnivoltaic at onboarding.
Partners Swap Applet Provisioned by Omnivoltaic for plan and swap operations

Applet Setup — Swap Products

Swap products are product.product records in Odoo created by the partner inside their own SA. They are SA-scoped — only the partner who created them can see and use them. No other partner or SA has visibility into another partner's swap products.

Creating a Swap Product

The partner creates swap products via the Odoo REST API or directly in Odoo. Each product carries two additional fields:

  • x_template_id — the ABS template string provided by Omnivoltaic at onboarding. Must match an existing ABS template exactly.
  • x_is_swap_product — a flag marking this product as a swap product, used to filter it from general catalogue products.
POST /api/products
Authorization: Bearer <jwt>
X-SA-ID: {partner_sa_id}

{
  "name":              "130kWh Pack",
  "x_template_id":     "B30-130 kWh (60 swp)",
  "list_price":        10.00,
  "currency_id":       "USD",
  "x_is_swap_product": true
}

Odoo creates the product and writes an ov.sa_product_assignment record linking it to the partner's SA. No other SA can see this product.

Fetching Swap Products in the Applet

Every time the applet starts (at login), it fetches the partner's swap products from Odoo:

GET /api/products?swap=true
Authorization: Bearer <jwt>
X-SA-ID: {partner_sa_id}

The SA filter (X-SA-IDov.sa_product_assignment) ensures only products belonging to this partner's SA are returned:

[
  { "id": 789, "name": "130kWh Pack", "x_template_id": "B30-130 kWh (60 swp)", "price": 10.00 },
  { "id": 790, "name": "60kWh Pack",  "x_template_id": "B30-60 kWh (30 swp)",  "price": 6.00  }
]

The applet caches these for the session. No hardcoded lists. No manual setup in the applet. If the partner adds a new product in Odoo it appears automatically at next login.

Selecting the Right Product Per Customer

Different customers can be on different plans. At registration the applet shows a dropdown populated from the fetched products:

Register Customer

Name          [ John Doe           ]
Phone         [ +255712345678      ]
Swap Product  [ 130kWh Pack       ▼]   ← partner picks for this customer
                  130kWh Pack          (60 swaps, 130 kWh)
                  60kWh Pack           (30 swaps, 60 kWh)

[ Register ]

The applet maps the selected product to its x_template_id and uses it in the CREATE payload. The partner sees only product names — the template_id string is handled internally.

SA Isolation

Partner A (SA-10) logs in → X-SA-ID: 10
  GET /api/products?swap=true → [130kWh Pack, 60kWh Pack]   ← SA-10 only

Partner B (SA-25) logs in → X-SA-ID: 25
  GET /api/products?swap=true → [Basic Plan, Economy Pack]   ← SA-25 only

Partner A never sees Partner B's products and vice versa. The X-SA-ID header and ov.sa_product_assignment enforce this automatically.


What Needs to Be Built

Item Description
x_template_id field on product.template Stores the ABS template string
x_is_swap_product flag on product.template Marks the product as a swap product for filtering
ov.sa_product_assignment Already exists — used for SA scoping
POST /api/products dual-write Creates product + writes ov.sa_product_assignment record
GET /api/products?swap=true Returns only SA-scoped swap products for the calling partner

Phase 1 — Customer Registration (One Time Per Customer)

Step 1 — Create the Customer in Odoo

The applet calls Odoo to create the customer inside the partner's SA:

POST /api/contacts
Authorization: Bearer <jwt>
X-SA-ID: {partner_sa_id}

{
  "name":  "John Doe",
  "phone": "+255712345678"
}

Response:

{
  "success": true,
  "id": 303025,
  "name": "John Doe"
}

The partner_id returned (303025) becomes the base for both identifiers:

customer_id:     "customer-303025"   ← "customer-" + partner_id
service_plan_id: "customer-303025"   ← same value, used as the plan key

Step 2 — CREATE: Provision the Plan

The applet immediately fires CREATE to the ABS:

Topic: emit/odo/service/plan/create

{
  "timestamp":       "2026-04-28T13:01:00.000000Z",
  "tenant_id":       "tenant-14",
  "correlation_id":  "odoo-create-plan-customer-303025",
  "source":          "odoo.abs_connector",
  "idempotency_key": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
  "actor":           {"type": "system", "id": "odoo-erp"},
  "data": {
    "action":               "CREATE_SERVICE_PLAN_FROM_TEMPLATE",
    "template_id":          "B30-130 kWh (60 swp)",
    "customer_id":          "customer-303025",
    "service_plan_id":      "customer-303025",
    "currency":             "USD",
    "odoo_subscription_id": "customer-303025"
  }
}

The applet waits for the SERVICE_PLAN_CREATED signal in the echo before proceeding.

ABS creates automatically: - Service account and payment account for the customer - 5 service states (quota, swap count, battery slot, energy, rate) - FSM swap cycle — ready for first swap


Step 3 — SYNC: Activate the Plan

On receiving the SERVICE_PLAN_CREATED echo, the applet automatically fires SYNC:

Topic: emit/odo/subscription/plan/customer-303025/sync

{
  "timestamp":       "2026-04-28T13:01:01.000000Z",
  "tenant_id":       "tenant-14",
  "correlation_id":  "sync-customer-303025-customer-303025",
  "source":          "odoo.abs_connector",
  "idempotency_key": "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5",
  "plan_id":         "customer-303025",
  "actor":           {"type": "system", "id": "odoo-erp"},
  "data": {
    "action":                  "SYNC_ODOO_SUBSCRIPTION",
    "odoo_subscription_id":    "customer-303025",
    "odoo_payment_state":      "paid",
    "odoo_subscription_state": "in_progress",
    "odoo_currency_id":        "USD",
    "odoo_amount_total":       10.0,
    "odoo_order_id":           "customer-303025",
    "odoo_order_name":         "customer-303025"
  }
}

After ODOO_SYNC_SUCCESS the customer's plan state becomes:

payment_state:      PAYMENT_CURRENT
subscription_state: SERVICE_ACTIVE

After Registration

customer_id:     "customer-303025"
service_plan_id: "customer-303025"   ← printed on card / stored in QR
plan_status:     SERVICE_ACTIVE

The service_plan_id (customer-303025) is printed on the customer's card or stored in their QR code. It is the only identifier needed for every future swap.


Phase 2 — Swap (Every Time the Customer Arrives)

The partner scans or enters the customer's service_plan_id in the applet.


Applet Step 1 — Query Customer State

Topic: request/swap/identify

{
  "tenant_id":      "tenant-14",
  "correlation_id": "identify-customer-303025",
  "source":         "odoo.abs_connector",
  "actor":          {"type": "system", "id": "odoo-erp"},
  "data": {
    "service_plan_id": "customer-303025",
    "customer_id":     "customer-303025"
  }
}

Applet displays:

Customer:       John Doe
Plan status:    SERVICE ACTIVE
Swaps left:     60
Energy left:    130 kWh
Battery in use: OVES Batt 070000

If plan_status is not SERVICE_ACTIVE the applet blocks the swap and shows an error.


Applet Step 2 — Scan Old Battery

The applet activates the BLE scanner. The attendant scans the barcode or NFC tag on the customer's current battery:

Scanning old battery...

[ Scan Battery ] ← triggers BLE scan

✓ Found: OVES Batt 070000
  Matches assigned battery for this customer

The applet validates that the scanned battery matches current_battery_id returned in Step 1. If it does not match, the applet shows an error and does not proceed.


Applet Step 3 — Scan New Battery

The attendant picks a charged battery from the rack and scans it:

Scanning new battery...

[ Scan Battery ] ← triggers BLE scan

✓ Found: OVES Batt 080012
  Charged and ready

Applet Step 4 — Collect Payment

The applet shows the amount due based on the customer's plan and initiates payment via PayAfrica directly — no Odoo involved:

Amount due:   10.00 USD

Payment method:  [ PayAfrica ▼ ]
Phone number:    [ +255712345678    ]

[ Confirm Payment ]

The applet calls PayAfrica directly and receives a payment_reference on success. Odoo is not in this call. Payment goes straight from the applet to PayAfrica.


Applet Step 5 — Physical Battery Exchange

Only at this point, after both scans and payment are confirmed, does the physical exchange happen:

  1. Take the customer's old battery (OVES Batt 070000)
  2. Give the customer the scanned charged battery (OVES Batt 080012)

Applet Step 6 — Record the Swap

Topic: emit/odo/swap/complete

{
  "timestamp":       "2026-04-28T13:15:00.000000Z",
  "tenant_id":       "tenant-14",
  "correlation_id":  "swap-customer-303025-001",
  "source":          "odoo.abs_connector",
  "idempotency_key": "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6",
  "actor":           {"type": "system", "id": "odoo-erp"},
  "data": {
    "service_plan_id":   "customer-303025",
    "customer_id":       "customer-303025",
    "old_battery_id":    "OVES Batt 070000",
    "new_battery_id":    "OVES Batt 080012",
    "kwh_dispensed":     52.7,
    "amount_charged":    10.0,
    "currency":          "USD",
    "payment_reference": "EXT-PAY-303025-001"
  }
}

Applet shows on confirmation:

✓ Swap Recorded

Swaps remaining:  59
Energy remaining: 77.3 kWh
New battery:      OVES Batt 080012

Note — Reporting from swap records

Every swap/complete payload published to ABS contains the full financial and energy picture for that swap:

Field What it represents
service_plan_id / customer_id Which customer was served
old_battery_id / new_battery_id Which batteries were exchanged
kwh_dispensed Energy delivered to the customer in that swap
amount_charged Revenue collected from the customer
currency Currency of the transaction
payment_reference PayAfrica reference — proof of payment

ABS stores every swap event. If the external partner ever needs a revenue report, energy-sold report, or payment audit, they query ABS directly for all swap events under their service_plan_id range. The data is all there — no Odoo record is needed to reconstruct it.

ABS playback to Odoo — audit log only

If ABS ever pushes swap events back to Odoo, they are written as read-only audit log entries only. No invoice (account.move), no sale order (sale.order), and no payment record (account.payment) is created in Odoo for external partner swaps. Those financial records belong in ABS and PayAfrica — Odoo must never duplicate them.

The purpose of the playback is purely observational: Omnivoltaic staff can see that a swap happened, when, and for which customer — without the record having any accounting effect inside Odoo.


Swap Phase — Odoo Dependency Check

None of the 6 swap steps touch Odoo.

Step What happens Technology Odoo?
1. Query customer state Verify plan active, get assigned battery MQTT → ABS No
2. Scan old battery BLE scan, validated against assigned battery BLE + local No
3. Scan new battery BLE scan of charged battery from rack BLE + local No
4. Collect payment PayAfrica called directly, returns payment_reference PayAfrica API No
5. Physical exchange Attendant hands over battery Physical No
6. Record the swap Swap event published, ABS decrements quota + updates battery MQTT → ABS No

Odoo is only involved at session start:

When Call Purpose
Login POST /api/auth/login Get JWT token
Login GET /api/products?swap=true Fetch partner's SA-scoped swap products
Registration only POST /api/contacts Create customer, get partner_id

After login and registration, every swap runs entirely on ABS + BLE + PayAfrica. Odoo is not in the swap critical path.


Full Lifecycle Summary

ONBOARDING (one time for the partner)
  └─ Partner receives: SA membership, JWT login, Partners Swap Applet,
                       valid template_id strings from Omnivoltaic
  └─ Partner registers swap products in applet (name + template_id + price)

REGISTRATION (one time per customer)
  └─ Applet: POST /api/contacts → partner_id = 303025
       customer_id     = "customer-303025"
       service_plan_id = "customer-303025"   ← same value
  └─ Applet fires CREATE → ABS provisions plan + accounts
  └─ On echo: applet fires SYNC → ABS activates: SERVICE_ACTIVE
  └─ Partner stores service_plan_id on customer card / QR

EVERY SWAP (no Odoo)
  └─ Partner scans "customer-303025" in applet
  └─ Applet queries ABS: plan active, current battery assignment
  └─ BLE scan: old battery → validated against assigned battery
  └─ BLE scan: new battery → charged battery from rack
  └─ Payment collected → payment_reference received
  └─ Physical exchange: old battery taken, new battery given to customer
  └─ Applet fires swap/complete → ABS decrements quota, updates battery

Where customer_id and service_plan_id Come From

Value Source Format
customer_id "customer-" + partner_id from POST /api/contacts "customer-303025"
service_plan_id Same as customer_id "customer-303025"

One value. One Odoo call. Used as both keys everywhere in ABS.


Key Design Principles

Principle Detail
One Odoo call per customer Only POST /api/contacts is called. No subscription purchase, no invoice, no order.
partner_id is the universal key "customer-{partner_id}" is used as both customer_id and service_plan_id. No separate plan ID generation needed.
Applet owns CREATE + SYNC The applet fires both MQTT calls directly. Odoo is not involved in plan creation or activation.
CREATE before SYNC The applet always waits for SERVICE_PLAN_CREATED echo before firing SYNC.
template_id is registered in the applet Valid template_id strings come from Omnivoltaic at onboarding. Partner enters them once into the applet's swap product registry.
No Odoo during swap The applet talks directly to the ABS via MQTT. Odoo is not in the critical path.
SA scope enforced at registration X-SA-ID header on POST /api/contacts ensures the customer is created inside the partner's SA.

Deferred — SA-Scoped Operation Log (Future Discussion)

This section describes a concept that has been designed but not yet built. It is recorded here so the intent and field design are not lost. Implementation is deferred until the reporting and playback requirements are confirmed.

The Problem

External partner operations — swaps, payments, energy dispensed — generate no Odoo financial records by design. ABS and PayAfrica hold the source of truth. However, there are scenarios where Omnivoltaic or the partner may want to query this data from within Odoo without going to ABS directly:

  • How much revenue did SA 42 collect this month?
  • How many kWh did a specific customer consume?
  • What is the PayAfrica reference for swap #7 for customer-303025?
  • Did this battery appear in more than one swap today?

None of these questions can be answered from current Odoo tables because the records don't exist there.

Proposed Model — ov.swap_payment_log

A lightweight, SA-scoped log table. Not an accounting record. No journal entries, no debit/credit, no tax, no effect on the balance sheet. Purely operational audit data.

Fields

Field Type Example Source
sa_id Many2one → ov.serviced_account SA 42 Resolved from tenant_id in ABS payload
partner_id Many2one → res.partner 303025 Stripped from "customer-303025"
service_plan_id Char "customer-303025" data.service_plan_id
customer_id Char "customer-303025" data.customer_id
swap_timestamp Datetime 2026-04-28T13:15:00Z timestamp (top-level)
old_battery_id Char "OVES Batt 070000" data.old_battery_id
new_battery_id Char "OVES Batt 080012" data.new_battery_id
kwh_dispensed Float 52.7 data.kwh_dispensed
amount_charged Float 10.00 data.amount_charged
currency_id Many2one → res.currency USD data.currency
payment_reference Char "EXT-PAY-303025-001" data.payment_reference — PayAfrica ref
correlation_id Char "swap-customer-303025-001" correlation_id
idempotency_key Char "c3d4e5..." Unique constraint — prevents duplicate writes
operation_type Selection swap Type of operation logged (see below)
source Selection abs_playback How the record was created
state Selection confirmed confirmed on successful write

Operation Types

The operation_type field is intentionally broad. Swap is the first operation type but the table is designed to hold other external partner operations as they are defined:

Value Meaning
swap Battery swap — the initial and primary use case
topup Future: customer account top-up payment
plan_renewal Future: plan renewal payment
refund Future: refund or credit issued
adjustment Future: manual correction entry

This means the same table grows with the product without schema changes — only new operation_type values are added.

What You Can Query

Report How
Revenue per SA per month SUM(amount_charged) WHERE sa_id=X AND swap_timestamp BETWEEN ...
Energy dispensed per SA SUM(kwh_dispensed) WHERE sa_id=X
Swaps per customer COUNT(*) WHERE partner_id=X AND operation_type='swap'
Payment audit All rows for service_plan_id with payment_reference
Battery utilisation All new_battery_id appearances — frequency per battery
Duplicate detection idempotency_key unique constraint blocks the same event being written twice

Sample Row

id  sa_id  partner_id  service_plan_id     swap_timestamp        amount  currency  kwh    payment_ref            old_batt          new_batt           op_type  source
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
1   42     303025      customer-303025     2026-04-28 13:15:00   10.00   USD       52.7   EXT-PAY-303025-001     OVES Batt 070000  OVES Batt 080012   swap     abs_playback

What Needs to Be Built (when this is approved)

Item Detail
ov.swap_payment_log model New Odoo model in abs_connector/models/
POST /api/swap-logs Endpoint for ABS to write playback events into Odoo
GET /api/swap-logs SA-scoped list endpoint for reporting, filterable by date / customer / operation_type
Idempotency guard Unique constraint on idempotency_key — reject duplicates with 409
Docs table update Update the "Odoo Objects Created" table to include this row