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 — ble-app)

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 can query ABS for all swap events under their service_plan_id range. The data is all there — no Odoo record is needed to reconstruct it.

A future integration could also have ABS push swap events back to Odoo asynchronously (as read-only journal entries or a custom swap-log model) for consolidated Omnivoltaic-level reporting, but this is not required for the swap workflow itself.


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.