ABS Docs Flow — Battery Swap Subscription Lifecycle¶
Related: Customer Lifecycle
This document traces a single customer's battery-swap subscription from the moment they sign a contract to the moment the system writes its final state and stops caring about them.
Source of truth:
abs-platformrepo —archived/battery-swap-backup-/bss-fsm.json,docs/models/bss/interactions/bss-fsm-interaction.puml, and agent code. Real state names, real inputs, real outputs.
What We Are Tracking¶
Every customer has one PlanState record in the ABS database with two parallel counters running at the same time:
PlanState {
id: "plan-nairobi-001"
service_state: "..." ← what the physical service is doing (battery possession)
payment_state: "..." ← what is happening with money
service_cycle: "battery-swap" ← which FSM template governs service
payment_cycle: "monthly" ← which FSM template governs payment
}
These two counters move independently. The payment side does not wait for the service side and vice versa. The system's job is to keep both moving correctly as real events arrive.
The Two FSM Machines (Every State & Transition)¶
Payment FSM — The Money Counter¶
States: INITIAL → DEPOSIT_DUE → CURRENT → RENEWAL_DUE → FINAL_DUE → COMPLETE
Inputs (events that cause transitions):
CONTRACT_SIGNED DEPOSIT_PAID RENEWAL_PAID
SUBSCRIPTION_EXPIRED QUOTA_EXHAUSTED FINAL_PAYMENT_PAID
Outputs (what the FSM signals when it transitions):
DEPOSIT_REQUIRED SERVICE_ACTIVATED RENEWAL_REQUIRED FINAL_PAYMENT_REQUIRED
Full transition table:
| From | Input | To | Output |
|---|---|---|---|
INITIAL |
CONTRACT_SIGNED |
DEPOSIT_DUE |
DEPOSIT_REQUIRED |
DEPOSIT_DUE |
DEPOSIT_PAID |
CURRENT |
SERVICE_ACTIVATED |
CURRENT |
SUBSCRIPTION_EXPIRED |
RENEWAL_DUE |
RENEWAL_REQUIRED |
CURRENT |
QUOTA_EXHAUSTED |
RENEWAL_DUE |
RENEWAL_REQUIRED |
RENEWAL_DUE |
RENEWAL_PAID |
CURRENT |
RENEWAL_REQUIRED |
RENEWAL_DUE |
FINAL_PAYMENT_PAID |
COMPLETE |
FINAL_PAYMENT_REQUIRED |
Service FSM — The Battery Counter¶
States: INITIAL → WAIT_BATTERY_ISSUE → WAIT_BATTERY_SWAP → SUSPENDED → WAIT_BATTERY_RETURN → COMPLETE
Inputs:
DEPOSIT_CONFIRMED BATTERY_ISSUED RENEWAL_CONFIRMED SERVICE_REQUESTED
SERVICE_SUSPENDED PAYMENT_OVERDUE QUOTA_EXHAUSTED SUBSCRIPTION_EXPIRED
SUBSCRIPTION_CANCELLED BATTERY_RETURNED SUBSCRIPTION_RENEWED PAYMENT_RECEIVED
QUOTA_RESET GRACE_PERIOD_OVER
Outputs:
SERVICE_READY SERVICE_ACTIVATED SERVICE_DENIED SERVICE_SUSPENDED ASSET_RETURN_REQUIRED
Full transition table:
| From | Input | To | Output | Meaning |
|---|---|---|---|---|
INITIAL |
DEPOSIT_CONFIRMED |
WAIT_BATTERY_ISSUE |
SERVICE_READY |
Money received — prepare to issue first battery |
WAIT_BATTERY_ISSUE |
BATTERY_ISSUED |
WAIT_BATTERY_SWAP |
SERVICE_ACTIVATED |
First battery handed to customer — normal operating state |
WAIT_BATTERY_SWAP |
RENEWAL_CONFIRMED |
WAIT_BATTERY_SWAP |
SERVICE_ACTIVATED |
Renewal OK — stay in normal operating state |
WAIT_BATTERY_SWAP |
SERVICE_REQUESTED |
WAIT_BATTERY_SWAP |
SERVICE_ACTIVATED |
Swap request — service is available, go ahead |
WAIT_BATTERY_SWAP |
SERVICE_SUSPENDED |
SUSPENDED |
SERVICE_SUSPENDED |
Explicit suspend command |
WAIT_BATTERY_SWAP |
SUBSCRIPTION_EXPIRED |
SUSPENDED |
SERVICE_SUSPENDED |
Subscription time ran out |
WAIT_BATTERY_SWAP |
PAYMENT_OVERDUE |
SUSPENDED |
SERVICE_SUSPENDED |
Payment overdue, block swaps |
WAIT_BATTERY_SWAP |
QUOTA_EXHAUSTED |
SUSPENDED |
SERVICE_SUSPENDED |
Monthly swap quota used up, block swaps |
SUSPENDED |
SUBSCRIPTION_RENEWED |
WAIT_BATTERY_SWAP |
SERVICE_ACTIVATED |
Back in business after renewal |
SUSPENDED |
PAYMENT_RECEIVED |
WAIT_BATTERY_SWAP |
SERVICE_ACTIVATED |
Back in business after payment |
SUSPENDED |
QUOTA_RESET |
WAIT_BATTERY_SWAP |
SERVICE_ACTIVATED |
Back in business after quota top-up |
SUSPENDED |
GRACE_PERIOD_OVER |
WAIT_BATTERY_RETURN |
ASSET_RETURN_REQUIRED |
Grace period expired — demand battery back |
WAIT_BATTERY_RETURN |
BATTERY_RETURNED |
COMPLETE |
FINAL_PAYMENT_REQUIRED |
Battery back in hand — done |
The Full Journey — Phase by Phase¶
Phase 1: Signing Up (Contract → Deposit)¶
What happens in the real world: A rider in Nairobi walks into a swap station and signs up for a monthly battery subscription. The operator records the contract in Odoo.
What happens in the system:
Odoo records the sale and sends an MQTT event:
Topic: emit/ABS/BSS/plan-nairobi-001/contract_signed
Payload: {
timestamp: "2026-04-29T08:00:00Z",
plan_id: "plan-nairobi-001",
tenant_id: "kenya-nairobi",
actor: { type: "system", id: "odoo" },
data: { type: "CONTRACT_SIGNED", payload: { customer_id: "cust-mwangi" } }
}
MQTTListenerService receives it → routes to Orchestrator → Agent checks it → fires into Payment FSM:
Payment FSM: INITIAL + CONTRACT_SIGNED → DEPOSIT_DUE
Output signal: DEPOSIT_REQUIRED
PlanState after:
payment_state: "DEPOSIT_DUE"
service_state: "INITIAL" ← service side has not moved yet
The system now knows a deposit is required. The DEPOSIT_REQUIRED output signal is what external systems (Odoo, SMS gateway) listen to in order to send the customer a payment link or notification.
Phase 2: Deposit Paid → Service Ready¶
What happens in the real world: The rider pays the deposit (e.g., via M-Pesa in Kenya). Odoo captures the payment.
Two events fire in sequence:
Event 1 — Payment confirmed (Payment FSM):
data: { type: "DEPOSIT_PAID" }
Payment FSM: DEPOSIT_DUE + DEPOSIT_PAID → CURRENT
Output signal: SERVICE_ACTIVATED
PlanState after:
payment_state: "CURRENT"
service_state: "INITIAL" ← still waiting
Event 2 — Deposit confirmed to service side (Service FSM):
data: { type: "DEPOSIT_CONFIRMED" }
Service FSM: INITIAL + DEPOSIT_CONFIRMED → WAIT_BATTERY_ISSUE
Output signal: SERVICE_READY
PlanState after:
payment_state: "CURRENT"
service_state: "WAIT_BATTERY_ISSUE" ← now waiting to hand out first battery
This is the key moment. The two FSMs are now unlocked:
- Payment is in CURRENT — money is good
- Service is in WAIT_BATTERY_ISSUE — waiting for the first physical battery to be handed over
Why two separate events? Because the payment confirmation (money arriving) and the service activation (deciding to turn service on) are handled by different systems and can happen at different times. They are not the same event.
Phase 3: First Battery Issued (Service Begins)¶
What happens in the real world: The station attendant hands the rider a charged battery. The station IoT device records the issuance.
The swap station publishes:
Topic: emit/ABS/EPS/station-nbi-001/allocate
Payload: {
data: { type: "BATTERY_ISSUED", payload: { battery_id: "bat-007", customer_id: "cust-mwangi" } }
}
Service FSM:
WAIT_BATTERY_ISSUE + BATTERY_ISSUED → WAIT_BATTERY_SWAP
Output signal: SERVICE_ACTIVATED
PlanState after:
payment_state: "CURRENT"
service_state: "WAIT_BATTERY_SWAP" ← THE NORMAL OPERATING STATE
WAIT_BATTERY_SWAP is where the customer lives for the entire active period of their subscription. The name means: "the customer has a battery, we are waiting for them to come back and swap it". Every single battery swap cycles through this state staying in it.
What the system tracks in the Service Account:
service_account: {
service_states: [
{
service_id: "battery-swap-daily",
current_asset: "bat-007", ← physical battery ID the customer has right now
used: 1, ← swaps consumed this cycle
quota: 30 ← swaps allowed this cycle (e.g. 30/month)
}
]
}
Phase 4: Normal Operations — Daily Swaps (The Loop)¶
What happens in the real world: Every day (or multiple times a day) the rider comes to a swap station. They hand back their depleted battery and receive a charged one. This is the core service event.
For each swap the agent processes W1–W5 workflows:
W1 — getRequiredAssetIds
Agent looks at the customer's bundle to find what fleet/asset type to request
→ asks ARM (Asset Resource Manager): "give me a battery for plan-nairobi-001"
→ ARM returns: fleet_id = "fleet-nairobi-standard-4ah"
W2 — intent emit/echo
System sends intent to swap station
Station confirms it can perform the swap
W3 — bindCustomerToLocation
Ties this customer to this specific station for this swap event
W4 — sendAssetAllocationSignal
Fleet instruction sent → station physically unlocks charged battery
ARM records: bat-007 (returned) → bat-008 (issued)
Echo received confirming physical swap complete
W5 — updateServiceStatesAndBilling
Updates quota: used 1 → used 2
Updates current_asset: "bat-007" → "bat-008"
If quota NOT yet exhausted: fires SERVICE_REQUESTED into service FSM
→ WAIT_BATTERY_SWAP + SERVICE_REQUESTED → WAIT_BATTERY_SWAP (stays put, output: SERVICE_ACTIVATED)
If quota NOW exhausted: fires QUOTA_EXHAUSTED into both FSMs (see Phase 5b)
PlanState during normal operations stays the same:
payment_state: "CURRENT"
service_state: "WAIT_BATTERY_SWAP"
The FSM does not move. The quota counter in service_account is what changes with each swap. The FSM only moves when something significant changes — expiry, suspension, termination.
Phase 5: Subscription Renewal (Time-based Expiry)¶
What happens in the real world: 30 days have passed. The subscription period has ended but the customer has not yet hit their swap quota. Odoo detects the subscription line has expired and fires an event.
data: { type: "SUBSCRIPTION_EXPIRED" }
Both FSMs are hit:
Payment FSM:
CURRENT + SUBSCRIPTION_EXPIRED → RENEWAL_DUE
Output: RENEWAL_REQUIRED
payment_state: "RENEWAL_DUE"
Service FSM (simultaneously):
WAIT_BATTERY_SWAP + SUBSCRIPTION_EXPIRED → SUSPENDED
Output: SERVICE_SUSPENDED
service_state: "SUSPENDED"
The customer is now suspended. They can still drive their motorbike (they have battery bat-008 in their hand), but they cannot do another swap at any station. The station attendant's app will show "subscription expired — renewal required".
SubscriptionAgent state at this moment:
subscriptionState: {
subscription_end_date: "2026-05-29T00:00:00Z",
days_remaining: 0,
is_active: false,
renewal_count: 0
}
Signals the agent emits:
- SUBSCRIPTION_EXPIRED (immediately at day 0)
- SUBSCRIPTION_EXPIRING (had been firing from day 27 onwards — the renewal_reminder_days threshold)
Phase 5b: Quota-based Expiry (Alternative Path)¶
What happens in the real world: The customer does 30 swaps in 18 days — they hit their monthly quota before the calendar month ends.
data: { type: "QUOTA_EXHAUSTED" }
Payment FSM:
CURRENT + QUOTA_EXHAUSTED → RENEWAL_DUE
Output: RENEWAL_REQUIRED
Service FSM:
WAIT_BATTERY_SWAP + QUOTA_EXHAUSTED → SUSPENDED
Output: SERVICE_SUSPENDED
Same end result as time-based expiry — suspended and waiting for payment.
Phase 6: Renewal — Customer Pays Again¶
What happens in the real world: The customer pays for another month (or buys more swaps). Payment arrives via M-Pesa → Odoo → event.
data: { type: "RENEWAL_PAID" }
Payment FSM:
RENEWAL_DUE + RENEWAL_PAID → CURRENT
Output: RENEWAL_REQUIRED ← (this output name is confusingly named; it means "renewal has been handled")
payment_state: "CURRENT"
Then a follow-up event to unblock the service side:
data: { type: "SUBSCRIPTION_RENEWED" }
Service FSM:
SUSPENDED + SUBSCRIPTION_RENEWED → WAIT_BATTERY_SWAP
Output: SERVICE_ACTIVATED
service_state: "WAIT_BATTERY_SWAP"
Customer is back in business. Quota counter resets. They can swap again.
SubscriptionAgent state update:
subscriptionState: {
subscription_end_date: "2026-06-29T00:00:00Z", ← extended by another 30 days
days_remaining: 30,
is_active: true,
renewal_count: 1 ← incremented
}
PaymentAgent state:
paymentState: {
account_balance: <previous + renewal_amount>,
overdue_balance: 0,
last_payment: "2026-05-05T10:30:00Z",
available_credits: <reset for new cycle>
}
Phases 4 → 5 → 6 repeat in a loop for however many months the customer keeps the subscription active.
Phase 7: Grace Period (Customer Does Not Renew in Time)¶
What happens in the real world: The customer's subscription expired (Phase 5). They are suspended. But they do not pay for several days. The grace period timer runs.
GracePeriodTimerAgent tracks this in the agent state:
// Daily check fires:
data: { type: "DAILY_CHECK" }
Agent compares: today vs suspension_date
If days_since_suspension > grace_period_days (e.g. 7 days):
fires: GRACE_PERIOD_OVER
Service FSM:
SUSPENDED + GRACE_PERIOD_OVER → WAIT_BATTERY_RETURN
Output: ASSET_RETURN_REQUIRED
service_state: "WAIT_BATTERY_RETURN"
This is an enforcement state. The system now actively demands the battery back. The customer can no longer use the service. Every station the customer visits will show "please return your battery — your subscription has not been renewed."
ARM (Asset Resource Manager) is notified that asset bat-008 must be recovered from customer cust-mwangi.
Phase 8: The Customer Decides — Three Possible Endings¶
Ending A: Customer Pays (Saves It)¶
If the customer pays during the grace period (before GRACE_PERIOD_OVER):
PAYMENT_RECEIVED fires into Service FSM:
SUSPENDED + PAYMENT_RECEIVED → WAIT_BATTERY_SWAP
Output: SERVICE_ACTIVATED
service_state: "WAIT_BATTERY_SWAP" ← back to normal
payment_state: "CURRENT" ← back to normal
They keep their battery and continue swapping. Life goes on.
Ending B: Battery Returned — Subscription Cancelled¶
What happens in the real world: The customer comes in, returns the battery at a station, and walks away. Either they chose to cancel, or they are returning during grace period enforcement.
data: { type: "BATTERY_RETURNED" }
Service FSM:
WAIT_BATTERY_RETURN + BATTERY_RETURNED → COMPLETE
Output: FINAL_PAYMENT_REQUIRED
service_state: "COMPLETE"
Payment FSM:
(If there are final fees outstanding)
RENEWAL_DUE + FINAL_PAYMENT_PAID → COMPLETE
Output: FINAL_PAYMENT_REQUIRED
payment_state: "COMPLETE"
Both FSMs are in COMPLETE. The plan is done.
ARM records: battery bat-008 has been returned from customer cust-mwangi and is back in the available pool.
Odoo creates a final invoice if any outstanding balance exists.
Ending C: Battery Lost¶
What happens in the real world: The motorbike is stolen. The battery is gone. Customer reports loss.
data: { type: "BATTERY_LOST" }
Service FSM:
WAIT_BATTERY_SWAP + BATTERY_LOST → (asset settlement state)
Two sub-paths exist:
- Continue service requested: Customer pays a replacement fee → new battery issued → back to WAIT_BATTERY_ISSUE (restart with new battery)
- Settlement complete: Customer pays fee and terminates → COMPLETE
ARM is notified to mark battery bat-008 as lost and initiate tracking/recovery if possible.
Complete State Map — Both FSMs Together¶
PAYMENT FSM
───────────
INITIAL
│ CONTRACT_SIGNED → output: DEPOSIT_REQUIRED
▼
DEPOSIT_DUE
│ DEPOSIT_PAID → output: SERVICE_ACTIVATED
▼
CURRENT ◄────────────────────────────────────────────┐
│ SUBSCRIPTION_EXPIRED or QUOTA_EXHAUSTED │
│ → output: RENEWAL_REQUIRED │
▼ │
RENEWAL_DUE │
│ RENEWAL_PAID → back to CURRENT ──────────────────►┘
│
│ FINAL_PAYMENT_PAID
▼
COMPLETE (terminal)
SERVICE FSM
───────────
INITIAL
│ DEPOSIT_CONFIRMED → output: SERVICE_READY
▼
WAIT_BATTERY_ISSUE
│ BATTERY_ISSUED → output: SERVICE_ACTIVATED
▼
WAIT_BATTERY_SWAP ◄───────────────────────────────────┐
│ SERVICE_REQUESTED → stays here (normal swap loop) │
│ RENEWAL_CONFIRMED → stays here │
│ │
│ SERVICE_SUSPENDED / PAYMENT_OVERDUE / │
│ QUOTA_EXHAUSTED / SUBSCRIPTION_EXPIRED │
│ → output: SERVICE_SUSPENDED │
▼ │
SUSPENDED │
│ SUBSCRIPTION_RENEWED → back to WAIT_BATTERY_SWAP ►┘
│ PAYMENT_RECEIVED → back to WAIT_BATTERY_SWAP ──►┘
│ QUOTA_RESET → back to WAIT_BATTERY_SWAP ──►┘
│
│ GRACE_PERIOD_OVER → output: ASSET_RETURN_REQUIRED
▼
WAIT_BATTERY_RETURN
│ BATTERY_RETURNED → output: FINAL_PAYMENT_REQUIRED
▼
COMPLETE (terminal)
What Each Agent Is Responsible For¶
| Agent | What it watches | What it decides |
|---|---|---|
| SubscriptionAgent | Days remaining, subscription_end_date |
When to fire SUBSCRIPTION_EXPIRED, SUBSCRIPTION_RENEWED, GRACE_PERIOD_WARNING |
| PaymentAgent | account_balance, overdue_balance, credit limit |
When to fire PAYMENT_OVERDUE, PAYMENT_RECEIVED, CREDIT_EXHAUSTED |
| QuotaAgent | used vs quota in service_account |
When to fire QUOTA_EXHAUSTED, QUOTA_RESET |
| GracePeriodTimerAgent | Days since suspension | When to fire GRACE_PERIOD_OVER |
| InventoryAgent | Battery availability at stations | Whether to approve or deny a swap request |
Every agent runs independently. They each watch one thing. When they decide something needs to change, they emit a signal. That signal becomes the FSM input. The FSM decides the next state. That is the whole system.
What "At Rest" Looks Like¶
A plan is at rest when:
PlanState {
service_state: "COMPLETE"
payment_state: "COMPLETE"
plan_state: { status: "TERMINATED" }
agent_state: { ... final snapshot ... }
updated_at: "2026-06-15T14:22:00Z"
}
Once both FSMs are in COMPLETE:
- No more MQTT messages will be routed to this plan
- No more FSM transitions are possible (COMPLETE has no outgoing transitions)
- The record stays in the database for audit/history
- ARM has the battery back in inventory
- Odoo has a closed subscription with all invoices settled
The plan is done. The system will never touch it again. Plan changes mid-subscription are not allowed by design — a new subscription requires a new plan.
Timeline — Full Happy Path¶
Day 0 Customer signs contract
payment_state: INITIAL → DEPOSIT_DUE
service_state: INITIAL (unchanged)
Day 0 Customer pays deposit
payment_state: DEPOSIT_DUE → CURRENT
service_state: INITIAL → WAIT_BATTERY_ISSUE
Day 0 Station attendant issues first battery (bat-007)
service_state: WAIT_BATTERY_ISSUE → WAIT_BATTERY_SWAP
← OPERATING STATE BEGINS
Day 1–27 Customer swaps battery daily
service_state: stays WAIT_BATTERY_SWAP
quota.used: 1 → 2 → ... → 27
Day 27 SubscriptionAgent fires: SUBSCRIPTION_EXPIRING (renewal reminder sent via SMS)
FSM states: unchanged
Day 30 Subscription expires
payment_state: CURRENT → RENEWAL_DUE
service_state: WAIT_BATTERY_SWAP → SUSPENDED
Day 30 Customer pays renewal immediately
payment_state: RENEWAL_DUE → CURRENT
service_state: SUSPENDED → WAIT_BATTERY_SWAP
quota.used reset to 0
← BACK TO OPERATING STATE
... (repeat months 2, 3, 4 ...)
Month 6 Customer decides to stop. Returns battery.
service_state: WAIT_BATTERY_SWAP → COMPLETE
payment_state: RENEWAL_DUE → COMPLETE
← AT REST
Timeline — Hard Path (Missed Payment)¶
Day 30 Subscription expires
payment_state: CURRENT → RENEWAL_DUE
service_state: WAIT_BATTERY_SWAP → SUSPENDED
Day 31 Customer does not pay. Has battery bat-008 at home.
Day 37 GracePeriodTimerAgent fires GRACE_PERIOD_OVER (7 day grace)
service_state: SUSPENDED → WAIT_BATTERY_RETURN
output: ASSET_RETURN_REQUIRED
ARM notified to recover battery
Day 38 Customer comes in, returns battery under pressure
service_state: WAIT_BATTERY_RETURN → COMPLETE
payment_state: RENEWAL_DUE → COMPLETE (final balance settled)
← AT REST
Battery bat-008 back in pool