Skip to content

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-platform repo — 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