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-ID → ov.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:
- Take the customer's old battery (
OVES Batt 070000) - 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/completepayload published to ABS contains the full financial and energy picture for that swap:
Field What it represents service_plan_id/customer_idWhich customer was served old_battery_id/new_battery_idWhich batteries were exchanged kwh_dispensedEnergy delivered to the customer in that swap amount_chargedRevenue collected from the customer currencyCurrency of the transaction payment_referencePayAfrica 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_idrange. 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. |