Skip to content

prd

Author Ridwan Egbeyemi (PM, Swap)
Status Ready for engineering pickup
Last updated 18 May 2026
Surfaces Atlas backoffice (desktop · existing user, new experience) · Swap app (mobile · existing user, expanded experience)
Companion docs Product overview · Cases & backend taxonomy
Prototypes swap-app support-cases-v3.html · Atlas swap-support-resolution.html

Today, when something goes wrong mid-swap, the station manager phones HQ, the support specialist opens three external tools to investigate, and money moves through informal channels. There’s no audit trail and no SLA. The two surfaces in this PRD close that gap:

  • Swap app gives the manager inline issue capture at the exact step where the issue manifests — no separate “report support ticket” mode. The manager stays in flow, parks the blocked swap, serves the next rider, and gets a push when support resolves it.
  • Atlas gives the support specialist a single-page resolution surface with the case, the parties (rider · manager · the other manager if relevant · evidence), and the action buttons that actually move money or batteries — without leaving the page. Every action is audit-logged automatically.

The 17 May overdraft mis-credit incident — where an overdraft was issued to a rider whose case had no wallet need — is the proximate driver for the new server-side eligibility guardrail (section 8.2).


Persona Surface Existing? Core job
Station manager (“Jamiu”) Swap app (mobile, 390×844) Yes — existing user, expanded experience Run swaps fast. When something blocks the swap, raise the issue in one tap at the step where it surfaced, with all context auto-captured. Park, serve next rider, resume on push.
Swap support specialist (“Tunde”) Atlas (desktop, 1440×900) Yes — existing user, new experience built for them Triage and resolve in-flight issues raised from the field. One page per case. Money / battery / station mutations happen inside the case page. SLA-bound.
Swap PM (“Ridwan”) Atlas — PM view Yes Read-only operational overview: hot stations, hot riders, anomaly flags, team scoreboard, audit feed. No mutation surface.

Out-of-scope personas (this round): rider, charge-hub operator, finance reconciler.


  • 14 operational scenarios (catalog in §5). Cover wallet, payment, battery-location, primary-station, and outstanding-overdraft cases.
  • Swap app: inline alerts at the manifesting step · parked-swap queue · push notification on resolution · in-app support log (read-only history) · catch-all support sheet for unlisted issues.
  • Atlas: support queue dashboard · issue-detail with embedded resolution panel · audit trail (per-specialist + team feed) · PM dashboard (KPIs, distribution, anomalies).
  • Backend services: 8 services (§6).
  • Mutations: rider-wallet credit/debit · station-wallet → rider-wallet transfer · cross-station fund release · battery release-and-reassign · battery station-claim · battery old-attribution · issue-corrected-battery-serial · primary-station reassignment · overdraft credit (gated).
  • Idempotency on every money-moving + inventory-mutating endpoint.
  • Click-to-call (copy-to-clipboard) on every contact card; audit-logged.
  • VCU manual-unlock fallback (scenarios 12 / 13 / 14). The bluetooth pairing that the manager runs at every swap stays in the swap app. The internet / manual-code resolution path is being redesigned to live entirely in-app with no human in the loop; until that design lands, the backoffice carries no VCU resolution surface.
  • Rider-facing app, charge-hub operator UI, KaaG telemetry (already shipped in Atlas), finance reconciliation tools.
  • Cross-org SSO; uses existing Atlas auth.
Term Definition Do not say
Primary station The rider’s home swap station — the one they’re registered at “Permanent station”
Station wallet Money pot owned by the station entity. The on-duty manager operates it; ownership and reconciliation belong to the station, not the operator “Manager’s wallet”, “manager’s float”, “station float”
Send to support The single label for any escalation from swap-app to backoffice. Internally support may route to engineering, but the manager-facing label is always this “Send to Ops”, “Send to engineering”, “Log to backoffice”, “Send to support team”
Swap One battery exchange transaction “Cycle”
Voltage 67.2V (full) · 50.4V (depleted) “SoC”, “%”
Battery serial BP-YYMMDD-NNNNNNNN (e.g. BP-251022-23910847) —
Case ID ISS-#### (e.g. ISS-8841) “Ticket”

Manager Swap App Atlas
───────── ──────── ─────
Open app → Dashboard (KPIs, parked queue, quick actions)
Tap FAB → Scan tricycle (QR or plate+phone)
→ Rider profile · wallet hero · "Draw daily fee ₦12,000"
Tap draw → [POST /v1/wallets/rider/debit + idempotency-key]
→ Wallet -₦12,000 · "Continue to keke connection"
Tap → VCU Bluetooth pair (connecting → connected)
Tap unlock → Locked → Unlocked (battery card visible)
Pull bat → "I've taken out the battery"
→ Scan new battery
→ Mileage capture (3 fields, auto-calc next return)
→ Swap complete · receipt
Manager Swap App Backend Atlas (support)
───────── ──────── ─────── ───────────────
…step where issue
manifests → Inline alert visible
Tap "Send [POST /v1/cases]
to support" → · captures rider, station,
manager, scanned values,
photo evidence
Case created → appears in queue
case.state = (sorted by SLA)
open · routed
round-robin
by severity
Specialist opens
case · sees all
context loaded
· calls manager
(copy phone) ·
takes resolution
action [POST mutation
endpoint w/ case_id]
Tap "Park" → Swap moves to parked queue,
manager serves next rider
case.state =
resolved
· audit entry
· push to manager
← Push: "Update ready for
<rider>"
Tap push → Resume swap at right step;
in-app status card shows
resolution outcome

14 in-scope scenarios. Numbering is sticky to historical IDs; gaps (12/13/14) are deferred VCU paths.

# Scenario Surfaced on (swap app) Resolution (Atlas) Severity
Station
1 Temporary swap (away from primary station) Rider profile · alert-mismatch · “Request authorization” sheet 3 decisions: allow + ₦500 fee · allow + waive fee · deny (rider returns to primary) · pattern flag if ≥3 temp this week Med
2 Primary station change Quick request from support page · 2-step form · pending → approved push Approve/deny · both stations + rider notified · effective immediately or scheduled Low
Wallet & payments — scenarios 4, 6, 7, 8, 8a share one entry point: the consolidated “How is the rider paying?” alert on the rider profile (5 actions). The five backend paths remain distinct.
3 Wallet not loading Rider profile · retry · park while support rebuilds Force rebuild from ledger · push manager when ready Med
4 Insufficient balance “Show funding accounts” → Opay / Paystack / Monnify n/a — resolves itself when funds arrive Med
5 Daily-fee deduction fails Danger alert · retry · or “Send to support” Engineering follow-up · safe retry (idempotent, no double-charge) Med
6 Funding not reflecting “Log rider’s receipt” · single OR multiple payments · per-payment receipt photo (camera OR gallery) · virtual-account + source-bank pickers Live settlement-feed tail · gated overdraft / wait / deny High
7 Station wallet → rider transfer failed “Pay from station wallet” then transfer fails · retry · escalate Confirm engineering fix landed · retry transfer on station’s behalf Med
8 Funds with another station manager “Funds at another station” · photo evidence + station/manager picker Click-to-call both managers · move ₦12,000 from station A’s wallet → rider wallet · resolve High
8a Funding account number invalid (NEW) “Funding account invalid” · capture provider · account number shown · rider’s bank error Support escalates internally to engineering to compare provider-generated VA vs what rider sees Med
Battery — every battery scenario (9/10/11) traces back to the same root cause: a field-support staff did a swap without using the app, leaving both batteries with the wrong digital position. Scenario 16 is a separate problem — a typo/worn printed serial that returns no inventory data when scanned. Scenarios 9/10/11 resolve with a single-click record mutation in Atlas (no relabel). Only 16 keeps the print-and-stick mechanic.
9 Release-and-reassign (old battery) scan-new-battery alert · “Send to support” → captured-context sheet · waiting card · resume push Support reviews + calls manager · one click detaches battery from previous keke and assigns to this station Med
10 Station claim (new battery) scan-new-battery alert · “Send to support” → captured-context sheet Support reviews + calls manager (and other-station mgr if needed) · one click claims for calling station Med
11 Old-battery attribution (rider) VCU-connected empty-state on “Current battery in keke” card · “Send to support” Support reviews keke history · one click attributes candidate serial to keke; swap-app refreshes Med
16 Serial mismatch (new battery) scan-new-battery — serial returns no data · “Send to support” Support finds closest match in inventory · pushes corrected serial back · manager prints + sticks + rescans (validated against issued serial) Low
Next-day follow-up
15 Outstanding overdraft blocks new swap Hard-block alert on rider profile · single CTA opens originating case · no manager override Atlas must reconcile or write off before another swap is allowed High

5.1 Service ↔ scenario matrix (for engineering scoping)

Section titled “5.1 Service ↔ scenario matrix (for engineering scoping)”
Scenario Rider wallet Stn wallet VA ingest Inventory Stn/mgr registry Case svc Audit
1 Temp swap ● ● ●
2 Primary change ● ● ●
3 Wallet not loading ● ● ●
4 Insufficient balance ● ● ● ● ●
5 Deduction fails ● ● ●
6 Funding not reflecting ● ● ● ●
7 Stn-wallet transfer fails ● ● ● ●
8 Funds at another station ● ● ● ● ●
8a Funding acct invalid ● ● ●
9 Release & reassign (old) ● ● ● ●
10 Station claim (new) ● ● ● ●
11 Old-battery attribution ● ● ● ●
15 Overdraft pending ● ● ●
16 Serial mismatch (new) ● ● ●

┌──────────────────────────────────────┐
│ DASHBOARD │
│ · KPIs (today's swaps, balance) │
│ · Parked swaps queue │
│ · Quick actions: │
│ [Transfer request] [Charge status]│
│ [Push battery] [Faulty swap] │
│ · Swap history │
│ · FAB → start swap │
│ · Bottom nav: Dashboard · Inventory │
│ · Wallet · Profile · Support │
└──────────────────────────────────────┘
│ start swap
▼
┌──────────────────────────────────────┐
│ SWAP JOURNEY (5 steps) │
│ 1. Scan tricycle (QR or plate+phone)│
│ 2. Rider profile [wallet alerts] │
│ 3. VCU connect / unlock [step 11] │
│ 4. Scan new battery [steps 9/10/16] │
│ 5. Mileage capture · receipt │
└──────────────────────────────────────┘
│ support sheet (any step)
▼
┌──────────────────────────────────────┐
│ SUPPORT LOG (read-only) │
│ · Open requests this manager raised │
│ · Status: Awaiting · Resolved · etc │
└──────────────────────────────────────┘

Patterns:

  • Inline alert on the step where the issue manifests — never a separate ticket mode. Class: .inline-alert (variants: info, warn, danger).
  • Bottom sheet for capture — never a centered modal. Top-left back button + centered drag handle.
  • Captured-context block inside every send-to-support sheet — rider, plate, scanned serial(s), station, manager, timestamp pre-filled and read-only.
  • Status card below alerts — Awaiting support (pulsing) → Approved by … (green check). Shared #status-waiting / #status-approved reused across scenarios.
  • Parked-swap queue on dashboard with timer (over-2-min badge in red), “Tap to resume” once support resolves.
  • Notification banner at top of any screen when a parked swap has an update — beep + vibrate.
  • Quick actions on the dashboard: 4 buttons (Transfer request, Charge status, Push battery, Faulty swap), wrap to two lines as needed.
┌──────────┬───────────────────────────────────────────────────┐
│ Sidebar │ TOPBAR · breadcrumb · bell · user pill │
│ · Swap ├───────────────────────────────────────────────────┤
│ Support│ SUPPORT QUEUE DASHBOARD │
│ (14) │ · KPI strip: open · my queue · resolved today … │
│ · Audit │ · Filter bar · Issues table (one row per case) │
│ trail │ - Click row → issue detail │
│ │ │
│ ├───────────────────────────────────────────────────┤
│ │ ISSUE DETAIL (split view) │
│ │ ┌────────────────────────┬─────────────────────┐ │
│ │ │ Left: case context │ Right: RESOLUTION │ │
│ │ │ · Banner (title/ID/SLA)│ panel (sticky) │ │
│ │ │ · Detail cards (facts) │ · form (select/text)│ │
│ │ │ · Contact cards w/ │ · Primary action(s) │ │
│ │ │ click-to-call │ │ │
│ │ │ · Evidence (photos) │ (no park / keep-open│ │
│ │ │ · Settlement feed │ buttons in this │ │
│ │ │ · Case history │ iteration) │ │
│ │ └────────────────────────┴─────────────────────┘ │
│ │ │
│ ├───────────────────────────────────────────────────┤
│ │ AUDIT TRAIL · per-specialist + team feed │
│ │ PM DASHBOARD (separate route) │
│ │ · Issue mix · hourly heatmap · scoreboard · │
│ │ anomaly cards │
└──────────┴───────────────────────────────────────────────────┘

Patterns:

  • Issue detail = split view. Left column is fact-only (no coaching/explanatory copy — already stripped). Right column is the resolution panel.
  • Resolution panel holds: action <select> + audit-trail textarea + primary action buttons. No “Save note · keep open” or “Park · awaiting …” ghost buttons.
  • Click-to-call on every contact card. Calls happen externally (no in-app VoIP); we copy the number to clipboard and write an audit entry Number copied.
  • Overdraft eligibility gate (§8.2) inside the funding-not-reflecting resolution card: a static eligibility line + mandatory confirmation checkbox; “Issue overdraft” button stays disabled until checked. Server-side check mirrors the UI.
  • Audit trail is append-only and immutable. Corrections are new entries that reference the original.
  • No park feature in issue detail. The manager parks on the swap-app side; the Atlas specialist either resolves or keeps the case open with a state=in-progress.

PostgreSQL. Money amounts in kobo (bigint); display side multiplies. Timestamps timestamptz UTC. UUIDs unless noted.

Column Type Notes
id uuid pk
licence_no text unique e.g. LAG-AM-04821
phone_e164 text unique
full_name text
primary_station_id uuid fk stations.id — nullable until first assignment
wallet_id uuid fk wallets.id
swap_eligible bool denormalised view of “no outstanding overdraft”; recomputed on overdraft credit + clearance
swap_eligible_blocking_case_id uuid fk nullable; non-null when swap_eligible=false, points to originating case
created_at timestamptz
Column Type Notes
id uuid pk
plate text unique e.g. ABC-1234-LA
vcu_id text e.g. VCU-LTE-1182
assigned_rider_id uuid fk nullable
current_battery_id uuid fk batteries.id — nullable when keke arrives empty (scenario 11)
Column Type Notes
id uuid pk
name text One of the canonical 8 (see reference — Asbestos / Honda / Opebi / Ijoko / Toll Gate Mega / etc)
manager_id uuid fk currently on-duty operator
wallet_id uuid fk the station wallet — money pot owned by the station, operated by whichever manager is on shift
Column Type Notes
id uuid pk
full_name, phone_e164 text
current_station_id uuid fk the station they’re operating now; updates on shift change

Managers do NOT have wallets. They operate the station.wallet_id while on shift.

Column Type Notes
id uuid pk
owner_type enum rider | station
owner_id uuid polymorphic — refs riders.id or stations.id
balance_kobo bigint derived from ledger entries; cached, recomputable
version int optimistic-concurrency token

7.6 wallet_ledger (immutable, append-only)

Section titled “7.6 wallet_ledger (immutable, append-only)”
Column Type Notes
id uuid pk
wallet_id uuid fk
entry_type enum debit | credit | overdraft_credit | overdraft_reconcile
amount_kobo bigint always positive; sign implied by entry_type
idempotency_key text unique client-generated; collision returns the original result
linked_swap_id uuid fk nullable
linked_case_id uuid fk required for overdraft_credit
linked_ledger_id uuid fk for paired debit↔credit transfers (rider↔station)
actor_type enum system | manager | support
actor_id uuid
created_at timestamptz
Column Type Notes
id uuid pk
serial text unique BP-YYMMDD-NNNNNNNN
state enum at-station | in-keke | at-charge-hub | in-transit | quarantined
current_station_id uuid fk nullable (null when in-keke or in-transit)
current_keke_id uuid fk nullable
voltage_v numeric(4,1) last-reported
production_batch text optional
Column Type Notes
id uuid pk
started_at, completed_at timestamptz
state enum in-progress | parked | completed | cancelled
parked_waiting_on enum nullable; ops-approval | opay-funding | funds-release | battery-release | battery-claim | battery-attribute | battery-mismatch | deduction-retry | acct-invalid
station_id, manager_id, rider_id, keke_id uuid fk
old_battery_id, new_battery_id uuid fk filled at the relevant scan step
fee_debit_ledger_id uuid fk nullable until daily-fee draw lands
linked_case_id uuid fk nullable; set when the swap gets blocked by a case
Column Type Notes
id uuid pk display form ISS-#### derived from a sequence
case_type enum one of the 14 scenarios — see §5 (temp-swap, primary-change, wallet-not-loading, funding-not-reflecting, daily-fee-deduction-failed, station-wallet-payment-failed, funds-at-another-station, funding-account-invalid, battery-release-and-reassign, battery-station-claim, battery-old-attribution, battery-serial-mismatch, outstanding-overdraft)
severity enum low | med | high
state enum see state machine §9.1
raised_by_manager_id uuid fk
raised_at_station_id uuid fk
subject_rider_id uuid fk nullable
subject_keke_id uuid fk nullable
subject_battery_serial text nullable; scanned input that may not yet resolve to a batteries.id
assigned_specialist_id uuid fk round-robin-routed
linked_swap_id uuid fk nullable
linked_engineering_ticket text e.g. ENG-2317 — for scenarios 5, 7, 8a
captured_context_json jsonb snapshot of {rider, plate, manager, station, scanned values, timestamps} as seen by manager when raising
evidence_uris text[] S3-equivalent paths to receipt photos / voice notes
created_at, resolved_at timestamptz
sla_target_at timestamptz computed at creation by severity
Column Type Notes
id uuid pk
actor_type enum manager | support | pm | system
actor_id uuid
action_type enum case.created | case.assigned | case.opened | case.resolved | case.denied | wallet.debit | wallet.credit | wallet.transfer | wallet.overdraft | battery.release-and-reassign | battery.station-claim | battery.attribute | battery.issue-corrected-serial | primary-station.reassign | phone.copied | note.added
target_kind, target_id text, uuid polymorphic
delta_json jsonb before/after where relevant
linked_case_id uuid fk nullable
created_at timestamptz

audit_events is append-only. Corrections create new rows referencing the original via delta_json.corrects = <id>.

Column Type Notes
id uuid pk
provider enum opay | paystack | monnify
provider_txn_id text unique dedup key
virtual_account text rider’s VA hit
sender_account text rider’s source account
sender_bank text for the slow-settlements analytics
amount_kobo bigint
received_at timestamptz provider’s clock
matched_to_rider_id uuid fk nullable; null when the VA isn’t bound to a known rider
matched_to_case_id uuid fk nullable; non-null when an overdraft reconciles against this inbound

REST + JSON. Auth = existing Atlas session for support; existing swap-app JWT for managers. All money-moving and inventory-mutating endpoints accept a client-generated Idempotency-Key header (UUID v4 from the client); the server stores request_hash → response for 24h and returns the cached response on key collision.

{ "error": { "code": "VALIDATION_FAILED", "message": "Human-readable", "details": {...} } }

Reserved codes: VALIDATION_FAILED (400), AUTH_REQUIRED (401), FORBIDDEN (403), NOT_FOUND (404), CONFLICT (409 — e.g. duplicate idempotency key with different payload), RATE_LIMITED (429), UPSTREAM_FAILED (502), INTERNAL (500).

Endpoint Purpose Notes
GET /v1/wallets/rider/:rider_id Read balance + history Streams to swap-app every 30s when a parked swap is active
POST /v1/wallets/rider/:rider_id/debit Manager-initiated daily-fee draw Body: { amount_kobo, linked_swap_id }. Idempotent
POST /v1/wallets/rider/:rider_id/credit Support-initiated credit (rare) Body: { amount_kobo, linked_case_id, reason }. Idempotent · linked_case_id required
POST /v1/wallets/rider/:rider_id/overdraft Gated: see eligibility check below Body: { amount_kobo, linked_case_id }. Idempotent · linked_case_id required

8.2.1 Overdraft eligibility (server-side guardrail)

Section titled “8.2.1 Overdraft eligibility (server-side guardrail)”

The POST /overdraft endpoint MUST reject the request unless ALL of:

  1. linked_case_id is present.
  2. The referenced case.case_type is in the wallet group: funding-not-reflecting · funds-at-another-station · station-wallet-payment-failed · daily-fee-deduction-failed · funding-account-invalid.
  3. The case is in state ∈ {in-progress, pending-external}.
  4. The acting specialist has role=support and is assigned to (or has claim-permission on) the case.
  5. The rider currently has swap_eligible=true (no other outstanding overdraft) OR the request explicitly carries replaces_previous_overdraft=true with the prior case ID.

If any check fails → 403 FORBIDDEN with code=OVERDRAFT_NOT_ELIGIBLE and a details object naming the failed precondition. The Atlas UI mirrors checks 1–4 with a mandatory confirmation checkbox; engineering enforces server-side.

This closes the loophole behind the 17 May incident where an overdraft was credited to a rider whose case had no wallet need.

When POST /overdraft succeeds:

  • Write wallet_ledger row with entry_type=overdraft_credit, linked_case_id.
  • Set riders.swap_eligible=false, riders.swap_eligible_blocking_case_id=<case_id>.
  • Emit wallet.overdraft audit event.

The flag clears when EITHER:

  • An inbound provider_funding_inbound matches the rider and is sufficient to settle the open overdraft (auto-reconcile job emits overdraft_reconcile ledger entry + flips flag); OR
  • A specialist explicitly writes off via POST /v1/cases/:id/write-off (manager has no override).

Swap-app reads riders.swap_eligible on every scan; when false, surfaces the outstanding-overdraft hard block alert (scenario 15) instead of the normal rider profile. Single CTA Open case ISS-XXXX deep-links to the originating case.

Endpoint Purpose Notes
GET /v1/wallets/station/:station_id Read balance · history
POST /v1/wallets/station/:station_id/pay-rider-subscription Station wallet pays the rider’s daily fee directly (scenario 4, station-pays path) Body: { rider_id, amount_kobo, linked_swap_id }. Idempotent. Single-step transfer that debits station wallet and marks the swap’s daily-fee as paid. Not the 2-step credit-rider-then-debit model.
POST /v1/wallets/station/:station_id/transfer-to-rider Used by scenario 7 after a failed station-wallet payment that engineering has since fixed; support retries from Atlas Body: { rider_id, amount_kobo, linked_case_id }. Idempotent · linked_case_id required
POST /v1/wallets/station/:from_station_id/release-to-rider Scenario 8: support moves ₦12k from station A wallet → rider wallet at station B Body: { rider_id, amount_kobo, linked_case_id }. Idempotent · linked_case_id required · writes two paired ledger entries linked by linked_ledger_id

The phrase “manager wallet” / “manager float” never appears in any endpoint or payload — the entity is the station wallet.

Endpoint Purpose Notes
POST /v1/providers/:provider/inbound-webhook Provider posts settlement events One endpoint per provider (Opay / Paystack / Monnify). Signature-verified. Writes a provider_funding_inbound row · attempts auto-match to a rider by VA + sender phone
GET /v1/cases/:id/settlement-tail?since=… Live tail for the funding-not-reflecting case page SSE/WebSocket · 5-min window · matched + unmatched events
Endpoint Purpose Notes
GET /v1/batteries/:serial Resolve scanned serial. Returns 404 if no record (powers scenario 16)
POST /v1/batteries/:serial/release-and-reassign Scenario 9: detach battery from a previous keke, assign to a station Body: { from_keke_id, to_station_id, linked_case_id }. Idempotent on (serial, to_station_id)
POST /v1/batteries/:serial/station-claim Scenario 10: move battery’s recorded location to the calling station Body: { to_station_id, linked_case_id }. Idempotent
POST /v1/kekes/:plate/attribute-old-battery Scenario 11: set current_battery_id for a keke that had none on record Body: { battery_serial, linked_case_id }. Idempotent
POST /v1/cases/:id/issue-corrected-serial Scenario 16: support pushes the closest-match serial back to the manager Body: { corrected_serial }. Once issued, the next scan from the manager is validated against this; mismatches are written to audit_events.action_type='battery.relabel-mismatch'

All four mutate batteries, write audit_events with full before/after delta_json, and trigger a push to the originating manager so the parked swap can resume.

Endpoint Purpose
GET /v1/stations/:id · GET /v1/managers/:id Read
POST /v1/cases/primary-change Scenario 2 — primary station reassignment request
POST /v1/cases/primary-change/:id/decide Support approves/denies
Endpoint Purpose Notes
POST /v1/cases Manager raises a case from the swap-app Body: { case_type, captured_context_json, evidence_uris[], linked_swap_id, raised_by_manager_id, raised_at_station_id, subject_* }. Idempotent on a client-generated key (so a flaky network doesn’t double-raise). Computes severity + sla_target_at from case_type. Auto-assigns by round-robin within severity weight.
GET /v1/cases?filter=… Atlas queue dashboard
GET /v1/cases/:id Atlas issue detail
POST /v1/cases/:id/claim Specialist claims an unassigned case
POST /v1/cases/:id/resolve Generic resolve — used when no specialised mutation endpoint applies (e.g. temp-swap decisions, primary-change decisions, wait/deny on funding) Body: { outcome, internal_note, decision_payload }. The outcome enum is one of the §5 scenario-decision options (e.g. temp-swap.allow-with-fee, temp-swap.allow-waive-fee, temp-swap.deny, funding.wait, funding.deny)
POST /v1/cases/:id/write-off Finance-tier action to clear a stuck overdraft Authz tighter (role=support-lead)
Endpoint Purpose
GET /v1/audit-events?actor_id=…&action_type=…&since=… Per-specialist + team feeds; filterable for PM dashboard
POST /v1/audit-events/phone-copied Client emits this when the manager (or specialist) taps a phone-copy button. Body: { contact_name, phone_e164, linked_case_id }

┌──────────────────┐
│ open │ ← POST /v1/cases (auto on creation)
└────────┬─────────┘
│ specialist claim/auto-assign
▼
┌──────────────────┐
│ in-progress │ ← /claim or auto-assigned
└────────┬─────────┘
┌────────────────┼────────────────┬──────────────┐
▼ ▼ ▼ ▼
┌─────────────┐ ┌────────────────┐ ┌──────────┐ ┌────────────┐
│ resolved │ │ denied │ │ escalated│ │ pending- │
│ │ │ │ │ │ │ external │
└─────────────┘ └────────────────┘ └──────────┘ └─────┬──────┘
│ external action lands
▼
in-progress
  • pending-external covers cases waiting on an upstream system (engineering fix, finance reconciliation, provider settlement). Auto-transitions back to in-progress when the external signal arrives.
  • escalated is a hand-off (e.g. funds-other-station that needs the other station’s manager to come on call). Same actor returns to it; it does not exit the support team’s queue.
  • Every state transition writes an audit_events row.
in-progress ──park──▶ parked ──push──▶ parked-with-update ──resume──▶ in-progress
│ │
└────────cancel────────────┴────▶ cancelled

The swaps.parked_waiting_on enum carries the resume target (opay-funding, battery-release, etc) so the swap-app knows which step to land on when the manager taps the push notification.

swap_eligible=true ──overdraft credit──▶ swap_eligible=false (blocking_case=<id>)
│
├── auto-reconcile from inbound ──▶ true
│
└── support write-off ──▶ true

Two channels: audit events (durable, queryable) and notifications (push + in-app banners).

Triggered by case-svc + wallet-svc + inventory-svc after the corresponding audit event lands. Always: audit-row first, notification second.

Event Audience Trigger
case.resolved Manager who raised it Specialist resolves case
case.denied Manager Specialist denies case
wallet.inbound-funding-arrived Manager + rider context Provider webhook matches a parked rider
battery.released-and-reassigned / .station-claimed / .attributed / .corrected-serial-ready Manager who raised Inventory mutation lands
primary-change.decided Both stations’ managers + rider Primary-change case resolves

Payload (mobile push): { title, sub, case_id?, parked_swap_id?, deep_link }. Manager taps → swap-app deep-links to the right resume step.

Already enumerated in §7.10 (audit_events.action_type). Three rules:

  1. Money-moving actions always write before/after balances in delta_json.
  2. Inventory mutations always write before/after batteries.current_station_id + current_keke_id.
  3. Phone copy (phone.copied) is logged but no PII leaves the audit trail — we store contact_name and a redacted phone_last4.

11.1 SLAs (proposed; leadership to sign off)

Section titled “11.1 SLAs (proposed; leadership to sign off)”
Severity Target time-to-resolve
Urgent in-swap blockers (funding-not-reflecting, funds-other, daily-fee-fail, funding-acct-invalid) 5–15 min
Medium (wallet-not-loading, battery-transfer scenarios, station-wallet-transfer-fail) 15 min
Low (primary-change, battery-conflict audit follow-up) 24 h

sla_target_at is computed at case creation. The queue dashboard sorts by (severity desc, sla_target_at asc). Cases over SLA flag red.

Every money-moving + inventory-mutating endpoint accepts Idempotency-Key. Server stores {key → response, request_hash} for 24h. Same key + same body returns the cached response (200/2xx). Same key + different body returns 409 CONFLICT.

  • Swap-app polls GET /v1/wallets/rider/:id every 30s while a parked swap is active (battery-light · stops polling on resume/cancel).
  • Atlas streams GET /v1/cases/:id/settlement-tail via SSE for the funding-not-reflecting case page.
  • Push notifications go through the existing FCM/APNs path.
  • Audit trail is immutable (append-only at the DB layer — no UPDATE/DELETE grants on audit_events).
  • wallet_ledger is immutable.
  • Click-to-call uses copy-to-clipboard only; no in-app VoIP, no number reveal to the rider, no SMS template sent.
  • Photo evidence is stored encrypted-at-rest; signed URLs with 24h TTL for downloads.
  • Overdraft endpoint is the only money-moving call with a server-side eligibility check beyond auth — see §8.2.1.
  • Per-endpoint p50/p95 latency dashboards (existing Datadog conventions).
  • Webhook ingest lag from each provider (Opay / Paystack / Monnify) charted on the funding-not-reflecting backend.
  • Failed-idempotency-conflict counter (signals client bugs).
  • Open cases over SLA counter (powers the PM dashboard’s anomaly cards).

Role Swap-app Atlas
Manager (station operator) Full swap journey · raise cases · view their own support log n/a
Support specialist n/a Queue dashboard · open/claim any case · run all resolution mutations in §8.5/§8.3/§8.7 · audit-trail own actions
Support lead n/a All specialist permissions + POST /v1/cases/:id/write-off
Swap PM n/a Read-only PM dashboard · cross-team audit feed · cannot mutate
Engineering on-call n/a Receive linked-engineering tickets (scenarios 5, 7, 8a); no Atlas mutation surface

13. Telemetry & metrics (powers PM dashboard)

Section titled “13. Telemetry & metrics (powers PM dashboard)”
Metric Definition Used for
Open cases now count(cases.state in (open, in-progress)) KPI strip
Median time-to-resolve (7d, by severity) KPI + anomaly detection
% cases over SLA (24h) PM scoreboard
Issue mix (7d) Distribution by case_type Distribution panel
Hourly heatmap (7d) count(cases) grouped by hour-of-day When-do-issues-come-in
Top stations by issue volume (7d) Anomaly flags (“Asbestos +63% vs baseline”)
Top riders by issue volume (7d) Rider-abuse signal
Specialist scoreboard Per-specialist: cases resolved · median TTR · % over SLA Team management
Funding-not-reflecting by sender bank (7d) From provider_funding_inbound.sender_bank Powers “which banks settle slowest?” report

One release. Atlas and swap-app deploy together; both surfaces depend on the case service + wallet eligibility flag. No partial rollout possible without breaking the parked-swap resume loop.

Pre-launch:

  • Backfill riders.swap_eligible=true for every rider (no historical overdrafts in scope).
  • Backfill stations.wallet_id to point at the existing station-wallet records (already exist in production under the legacy “float” naming).
  • Run case-service migrations on an empty cases table (no historical case data to import).

Post-launch:

  • One-week heads-up to the 8 station managers via WhatsApp group with the new in-app flow demoed.
  • Two-week soft launch with support team on-call extended to 12h/day before scaling SLA enforcement to 24/7.

  1. Station wallet top-up policy. Today stations self-fund their wallet (often the on-duty manager fronts the cash) — do we centralise that in Atlas v2 this round, or defer? Owner: Ridwan + Finance.
  2. Overdraft accounting. Where do unmatched overdrafts live in our books while waiting for bank settlement? Owner: Finance.
  3. Overdraft ceiling. When the bank is slow on a funding-not-reflecting case, support overdrafts up to ₦X per rider per day. What’s X? Owner: Finance.
  4. Primary station change cooldown. Prevent the same rider from changing primary station more than once a month? Prevents wallet-arbitrage gaming. Owner: Ridwan.
  5. SLA targets. §11.1 numbers are proposed — need leadership sign-off.

UAT is a manual checklist exercised by the swap PM + one support specialist + one station manager on a staging environment with seeded data. Test cases below are written so a tester unfamiliar with the codebase can execute them.

  • 8 stations created with names: Asbestos · Honda · Opebi · Ijoko · Toll Gate Mega · Plus 3 more from the canonical list.
  • 1 manager per station, each with valid swap-app credentials.
  • 5 test riders, each with a rider wallet at one of the 8 stations as primary.
  • 1 test support specialist + 1 test PM account in Atlas.
  • 100 batteries in inventory across the 8 stations.
  • Provider webhooks pointed at a mock harness that can inject inbound settlement events on demand.

16.2 Cross-cutting checks (run once before scenarios)

Section titled “16.2 Cross-cutting checks (run once before scenarios)”
ID Check Pass criterion
C-1 Swap-app login Manager opens app with seeded credentials, lands on dashboard with their station name visible
C-2 Atlas login Specialist opens Atlas, lands on Support queue dashboard
C-3 Bottom-nav reachability Every screen in the swap app exposes the bottom nav · Support entry visible
C-4 Audit trail reachability Atlas sidebar shows “My audit trail” · clicking renders empty-or-historical state
C-5 Terminology audit Open every prototype screen · grep the UI text · no occurrence of “permanent station”, “manager wallet”, “manager float”, “station float”, “Send to Ops”, “Send to engineering”, “Log to backoffice”
C-6 No park button in Atlas issue detail Open any case · the right-column resolution panel shows only the primary action button(s) — no “Park”, no “Keep open · ping me”, no “Save note · keep open”
ID Step Pass criterion
H-1 Manager scans tricycle (QR or plate+phone) Rider profile loads with wallet hero
H-2 Wallet has ≥ ₦12,000 · tap “Draw daily fee” Wallet balance drops by ₦12,000 · wallet.debit audit event written · idempotency key recorded
H-3 Re-tap “Draw daily fee” with same idempotency key Server returns the same response · no double charge · wallet unchanged
H-4 Tap continue · VCU pairs (Bluetooth) · tap unlock Battery card visible · door illustration animates · audible clunk plays
H-5 Scan new battery serial that exists Validation passes · mileage screen renders
H-6 Fill mileage · complete Receipt screen with next-return mileage shown · audit swap.completed

For each, the tester drives the swap app on the manager side and the support specialist drives Atlas. Mark pass/fail per row.

Scenario 1 — Temporary swap (away from primary station)

Section titled “Scenario 1 — Temporary swap (away from primary station)”
ID Step Pass criterion
S1-1 Manager scans a rider whose primary station is NOT this station Inline Temporary swap alert visible · primary station name shown
S1-2 Tap “Request authorization” → fill reason → “Send to support” Case created in Atlas with case_type=temp-swap · captured-context includes rider, plate, stations, reason · parked card on dashboard
S1-3 Atlas: support opens case · resolution panel exposes three distinct buttons: “Allow swap · charge ₦500 fee”, “Allow swap · waive fee”, “Deny · rider returns to primary station” All three present · no other action options
S1-4 Support clicks “Allow · waive fee” Push lands on manager phone · status card on swap-app updates to approved · audit shows case.resolved with outcome=temp-swap.allow-waive-fee · daily fee not increased by ₦500
S1-5 Repeat with “Deny” Manager sees denied status · daily fee not drawn · swap cancellable
ID Step Pass criterion
S2-1 Manager: Support page → “Reassign primary station” quick action Step-1 lookup screen renders
S2-2 Scan/enter plate · proceed · review screen shows current primary + new primary Both stations visible · rider’s address distance displayed
S2-3 Submit reason → pending status Case created in Atlas with case_type=primary-change · status card shows pending
S2-4 Atlas resolves with “Approve · effective immediately” Push notification fires · riders.primary_station_id updated · swap-app status flips to approved · both stations’ managers receive a notification

Scenarios 4 / 6 / 7 / 8 / 8a — Wallet group

Section titled “Scenarios 4 / 6 / 7 / 8 / 8a — Wallet group”
ID Step Pass criterion
S4-1 Manager scans rider with empty wallet Consolidated “How is the rider paying?” alert with 5 actions visible
S4-2 Tap “Pay from station wallet” → confirm One-step transfer: station wallet -₦12,000 · rider’s daily-fee marked paid · NO 2-step credit-rider-then-debit
S6-1 Tap “Log rider’s receipt” · select Single payment · attach receipt photo Photo affordance offers BOTH “Take photo” AND “Upload from gallery” buttons · selecting one collapses to the chosen one
S6-2 Submit reconcile Case created with case_type=funding-not-reflecting · evidence URI present
S6-3 Atlas: open case · resolution panel Eligibility line shows Case type: Funding not reflecting · eligible · confirmation checkbox visible · “Issue overdraft” button disabled until checked
S6-4 Check the box · click “Issue overdraft & resolve” Wallet credited · riders.swap_eligible=false · ledger row with entry_type=overdraft_credit, linked_case_id=<id>
S6-5 Attempt to call POST /v1/wallets/rider/:id/overdraft with linked_case_id pointing at a non-wallet case (e.g. battery-release) Server returns 403 OVERDRAFT_NOT_ELIGIBLE · no ledger row written
S6-6 Attempt to call POST /overdraft with NO linked_case_id Server returns 400 VALIDATION_FAILED
S7-1 Manager: “Pay from station wallet” → simulate failure Inline alert “Station-wallet payment failed” · station wallet balance unchanged (verify GET /v1/wallets/station/:id) · “Send to support” button visible
S7-2 Atlas: open case · banner reads “Station wallet top-up to rider failed” (NOT “Manager top-up”) Banner copy correct · transfer-attempt log shows from=station_<station-slug> (NOT from=mgr_<name>) · primary action reads “Retry transfer from station wallet”
S8-1 Manager: “Funds at another station” · select the other station’s manager from picker Captured-context shows both managers · evidence attached
S8-2 Atlas: click-to-call both managers (phone-copy buttons) Numbers copied to clipboard · audit.phone.copied events written · last-4 redaction visible in audit log
S8-3 Submit “Approve transfer & resolve” Two paired ledger entries written (station_A.debit + rider.credit) sharing linked_ledger_id
S8a-1 Manager: tap “Funding account invalid” → fill provider + account + bank-error → submit Case created case_type=funding-account-invalid · parked card on dashboard
S8a-2 Atlas: resolution available · escalation marker visible to engineering Linked engineering ticket field populated · case state pending-external

Scenarios 9 / 10 / 11 / 16 — Battery group

Section titled “Scenarios 9 / 10 / 11 / 16 — Battery group”
ID Step Pass criterion
S9-1 Manager scans a new battery serial that’s still attached to KOZ-3829-LA per inventory Inline alert “Release-and-reassign” visible · “Send to support” button
S9-2 Atlas: one click “Release & reassign · resolve” batteries.current_keke_id flips to null · current_station_id set to calling station · paired audit entries · push to manager · parked swap can resume
S10-1 Manager scans a battery recorded at a different station Inline alert “Station claim” visible
S10-2 Atlas: resolve batteries.current_station_id flips to calling station · audit entry · push
S11-1 Manager pairs VCU on a keke with current_battery_id=null VCU Current battery in keke card shows empty state · alert “Old-battery attribution” visible
S11-2 Atlas: confirm candidate serial with manager (phone copy) · resolve kekes.current_battery_id set · audit entry · swap-app refreshes the VCU card so manager can advance
S16-1 Manager scans a serial returning no inventory Inline alert “Serial mismatch” visible
S16-2 Atlas: pushes back the closest-match serial cases.captured_context_json.corrected_serial set · push to manager with a clipboard-copy chip · manager prints, sticks, rescans · next scan validates against corrected serial · mismatch on rescan writes battery.relabel-mismatch audit

Scenario 15 — Outstanding overdraft (next-day)

Section titled “Scenario 15 — Outstanding overdraft (next-day)”
ID Step Pass criterion
S15-1 Pre-condition: rider has an open overdraft from S6 yesterday · swap_eligible=false
S15-2 Manager scans this rider Rider profile shows the hard-block alert “Outstanding overdraft · cannot swap” with the unsettled amount · Send to support is the ONLY action · no “Pay from station wallet” or other overrides
S15-3 Inject an inbound matching the rider Auto-reconcile fires · ledger row written · swap_eligible flips to true · hard-block alert clears on next refresh
ID Check Pass criterion
X-1 Idempotency replay (any endpoint accepting Idempotency-Key) Replaying same body returns same response · same key + different body returns 409 CONFLICT
X-2 Audit immutability Attempt to UPDATE / DELETE an audit_events row directly via DB → permission denied
X-3 Wallet ledger immutability Same as X-2 against wallet_ledger
X-4 SLA enforcement Create a high-severity case · advance the clock 16 min · queue dashboard row shows red over-SLA flag · anomaly card on PM dashboard counts it
X-5 Push deep-link Tap a resolution push on a manager phone · swap-app deep-links to the correct resume step (matches swaps.parked_waiting_on)
X-6 Live settlement tail Open a funding-not-reflecting case · inject 3 inbounds via mock harness · all 3 render in the terminal-style block within 2s of webhook ingest
X-7 Click-to-call audit Tap every phone-copy button on a case · every tap writes one audit.phone.copied row · audit log filter for “this case” shows all entries
X-8 Read-only PM Log in as PM · attempt to click any resolution action button · all are disabled/hidden · attempt API call → 403

UAT passes when:

  • 100% of cross-cutting (C-, X-) checks pass.
  • 100% of happy-path checks (H-*) pass.
  • 100% of in-scope scenarios (S1, S2, S4-S8, S8a, S9-S11, S16, S15) pass.
  • Zero high-severity defects open.
  • All open questions in §15 have been addressed or explicitly deferred with a follow-up ticket.