Admin Dashboard — UI/UX Mockup
Admin ← Gallery
Screen 1 of 17 — Sign-in
Sign in to the operations console
Work email
Password
Trust this device for 30 days
or
Restricted to authorized Twende staff. All actions are audited.
Need access? Contact it@twende.ug
Component
🔐 Admin Sign-in Screen 1
  • Email + password — same as enterprise SaaS, no passwordless for admin (audit + reversibility)
  • Google Workspace SSO option below — preferred path for staff with twende.ug accounts
  • "Trust this device 30 days" — bypasses 2FA on subsequent logins from same device fingerprint
  • Forgot password link — routes to email-based reset flow
  • Footer note: restricted access + audit warning sets serious tone
  • Failed attempts visually muted to deter brute-force attackers
Architectural Decisions
🏗️ Architecture — Admin Auth
  • Auth Service has separate flow for role:admin — different rate limits, longer session, different audit logging
  • Password hashed with Argon2id (more secure than bcrypt for admin scope)
  • Failed login attempts tracked per email + IP — 5 fails = 30-min lockout + alert to SecOps
  • Device trust uses signed cookie + device fingerprint hash (canvas + UA + timezone)
  • Session JWT short-lived (4 h) with refresh; force re-auth on every action requiring SuperAdmin scope
  • SSO via Google OAuth2 — verified domain @twende.ug only (no Gmail consumer accounts)
  • All sign-in attempts (success + fail) logged to admin_audit_log with IP, UA, location
API & Data Flow
🔌 API — Admin Auth
  • POST /admin/auth/login { email, password, device_fingerprint? } → { session_id, requires_2fa: true|false }
  • If requires_2fa: client redirects to /admin/auth/2fa
  • If trusted device: skip 2FA, issue JWT immediately
  • POST /admin/auth/sso/google { id_token } → JWT (validated against Google JWKS)
  • POST /admin/auth/forgot-password { email } → email link with one-time reset token (10-min TTL)
  • Failed attempts increment Redis admin_login_fails:{email}:{ip} counter
Edge Cases & Implementation Notes
⚙️ Implementation Notes
  • Password breach check via HaveIBeenPwned API on signup + every 30 days for active admins
  • Compromised credential detected: force password reset + audit + Slack alert to SecOps
  • SSO is preferred — staff with Google Workspace bypass password rotation entirely
  • Inactive admin (not signed in > 90 days): account auto-deactivated, requires SuperAdmin re-enable
  • Field staff in remote offices: device trust avoids 2FA fatigue while maintaining MFA on first device
  • Phishing protection: Auth Service issues nonce in OAuth state — server validates origin matches
Two-factor verification
Open your authenticator app (Google Authenticator, Authy, 1Password) and enter the 6-digit code.
7
3
2
Code refreshes in 28s
⚠️ Back-up codes (10 single-use) were generated when you set up 2FA. Print or store them in a password manager.
Component
🔢 2FA Verification Screen 2
  • Standard TOTP code entry — 6-digit, auto-advance focus, paste-aware
  • App-name suggestions help low-experience admins (Google Authenticator, Authy, 1Password)
  • Refresh countdown helps user know if their code is about to roll over
  • Backup code fallback — for lost-device scenarios
  • Educational note about backup codes during initial setup
  • Required for all roles except super-trusted devices (30-day trust)
Architectural Decisions
🏗️ Architecture — 2FA
  • TOTP standard (RFC 6238) — works with any authenticator app, no Twende-specific app needed
  • Secret stored encrypted in PostgreSQL admin_2fa_secrets (AES-256, KMS-managed key)
  • Backup codes: 10 single-use codes generated on 2FA setup, stored hashed (bcrypt)
  • SMS-based 2FA NOT supported (SIM-swap risk for admin accounts)
  • WebAuthn / hardware keys (YubiKey) supported as alternate factor — preferred for SuperAdmin
  • 2FA bypass not allowed even by SuperAdmin — requires self-service backup code or IT reset (audit-logged)
API & Data Flow
🔌 API — 2FA
  • POST /admin/auth/2fa/verify { session_id, code } → JWT { access_token, refresh_token }
  • POST /admin/auth/2fa/backup-code { session_id, backup_code } → JWT (consumes the code)
  • POST /admin/auth/2fa/setup → { totp_secret, qr_code_uri, backup_codes[] } (one-time, on enrollment)
  • POST /admin/auth/webauthn/verify { session_id, assertion } — for hardware key users
  • Failed 2FA: 3 attempts then session invalidated, must restart login
Edge Cases & Implementation Notes
⚙️ Implementation Notes
  • Time skew: server accepts ±1 window (30 s before / after) to handle phone clock drift
  • Lost authenticator + lost backup codes: requires in-person verification at Twende HQ
  • 2FA setup mandatory for all admin roles within 7 days of first login (grace period)
  • SuperAdmin must use hardware key (YubiKey) — TOTP not allowed for highest privilege
  • Authenticator app outage (rare): IT can issue temporary 24-h bypass with full audit trail
  • 2FA reset request: requires 2 SuperAdmins to approve (4-eyes rule, audit-logged)
Continue with Google Workspace
👤
stella.atim@twende.ug
Twende Operations · SuperAdmin
✓ Email verified by Google Workspace · domain twende.ug
By continuing, you grant Twende Ops console access tied to your Workspace account. Sign-out from Google ends this session.
Component
🌐 SSO Sign-in Screen 3
  • Google Workspace OAuth — preferred for all twende.ug staff
  • Identity card preview confirms which Google account will be used
  • Domain verification badge — only @twende.ug allowed (no @gmail.com)
  • Role pre-fetched from Twende back end based on email — shown for transparency
  • "Use a different account" — for shared computers
Architectural Decisions
🏗️ Architecture — SSO
  • Google OAuth2 PKCE flow — token exchanged server-side (Auth Service)
  • id_token verified against Google JWKS (cached, refreshed daily)
  • Domain whitelist enforced at Auth Service: hd claim must be "twende.ug"
  • First SSO sign-in auto-creates admin account with role=ops (least privilege)
  • Role elevation requires SuperAdmin approval — never auto-granted
  • Workspace de-provisioning (employee leaves): nightly job revokes admin access
  • SSO bypasses 2FA at Twende level (Workspace already enforces 2FA org-wide)
API & Data Flow
🔌 API — SSO
  • Client: redirects to Google OAuth URL with PKCE challenge
  • Google → POST /admin/auth/sso/google/callback { code, state }
  • Server exchanges code → id_token + access_token; verifies id_token
  • Server checks email domain → looks up admin user → issues JWT
  • POST /admin/auth/sso/check-account { email } → { exists, role, requires_signup }
Edge Cases & Implementation Notes
⚙️ Implementation Notes
  • Workspace account suspended (HR action): admin auto-locked at next login attempt
  • Multi-tenant Google account: hd claim explicitly checked, never trust just the email domain
  • SSO outage (Google down): emergency password fallback for SuperAdmin only — audit-logged
  • Account name change in Workspace: synced on next login, audit logs updated retroactively
  • Contractor accounts: time-limited (max 90 days), auto-deprovision on expiry
Reset your password
Enter your work email. If it's registered, we'll send a password-reset link valid for 10 minutes.
Work email
or
💡 Tip: if you use Google Workspace SSO, you don't have a password — just sign in with Google.
Locked out and no email access? Contact it@twende.ug — IT will verify your identity in person before reset.
Component
🔑 Forgot Password Screen 4
  • Single-field flow — email only, no security questions (anti-social-engineering)
  • Anti-enumeration: response is always success — never reveals whether email exists
  • SSO reminder helps Workspace users avoid unnecessary password reset
  • Lockout fallback: in-person verification at IT — protects against attacker email takeover
  • Plain prose explanation builds trust without legalese
Architectural Decisions
🏗️ Architecture — Reset
  • Reset token: random 32-byte URL-safe, stored hashed in Redis with 10-min TTL
  • One-time use — token invalidated immediately on first click
  • Email sent via Comms Service (SendGrid) — different sending domain than user emails
  • Anti-enumeration: same UI feedback regardless of whether email exists
  • Rate limit: max 3 reset requests per email per hour, 10 per IP per hour
  • Reset success: forces 2FA re-enrollment (assumed compromise scenario)
  • Reset event always audit-logged with IP and UA
API & Data Flow
🔌 API — Reset
  • POST /admin/auth/forgot-password { email } → { sent: true } (always)
  • If email exists: server generates token, sends email via SendGrid
  • POST /admin/auth/reset-password { token, new_password } → { success, requires_2fa_setup: true }
  • Password validated for strength: min 12 chars, mix of types, not in HIBP breach list
Edge Cases & Implementation Notes
⚙️ Implementation Notes
  • SSO-only user requests reset: flow shows clear "you don't have a password" message + Google sign-in CTA
  • IT-mediated reset (last resort): requires SuperAdmin approval + in-person ID verification
  • Password reuse blocked: cannot reuse last 12 passwords (admin_password_history table)
  • Successful reset triggers session invalidation across all devices
  • Suspicious reset (e.g. from unfamiliar IP): SecOps notified via Slack webhook
  • Reset email pixel-tracking: detect attacker phishing attempts
🗺️ Live Operations Map
Kampala · Real-time · Last updated 2 s ago
Active Trips
42
↑ +8 vs 30 min ago
Online Drivers
138
↑ Peak hour
Avg Wait Time
3.2 min
↓ -0.4 min
Risk Alerts
3
⚠️ Requires action
Kampala — Live Trip Map ● LIVE
Wandegeya
City Centre
Nakasero
Kololo
Katwe
In Progress
Matching
Risk Alert
Driver
Google · Map data ©2026 · Imagery
Active Trips 3 alerts
#1023IN_PROGRESS
Acacia Mall → Nakasero
Kato Moses · 4.8 ★ · Boda
#1024MATCHING
Wandegeya → Makerere Hill
Searching drivers…
#1025RISK HIGH
Katwe → City Centre
Ssali James · GPS anomaly
#1026IN_PROGRESS
Garden City → Ntinda
Namukasa Sarah · 4.9 ★
#1027IN_PROGRESS
Kololo → Muyenga
Odong Patrick · Car
Component
🗺️ Live Ops Map Screen 1
  • Real-time map of all active trips and online drivers in the selected city
  • Color-coded dots: green (in-progress), amber (matching), red (risk/anomaly), blue (arriving)
  • Filter bar: by city, service type (boda/car/delivery), trip status, or risk level
  • Click a driver/trip dot → side panel opens with full trip context and driver details
  • KPI tiles row above map: Active Trips, Online Drivers, Avg Wait, Open Alerts
  • Live trip list sidebar: sorted by risk level — critical trips float to top
Architectural Decisions
🏗️ Architecture — Live Map
  • Admin web app: React 18 + TypeScript + Leaflet.js (open-source map, cheaper than Google Maps Embed)
  • Real-time data: WebSocket connection to API Gateway → Admin Feed Service (aggregates Kafka topics)
  • Admin Feed Service consumes: trip.status.changed, driver.position, trip.anomaly Kafka topics
  • KPI tiles: ClickHouse OLAP queries (rolling 30-min windows) — not PostgreSQL to avoid OLTP pressure
  • RBAC: Ops role sees all trips; City Manager sees own city; SuperAdmin has global + config access
  • Audit trail: all admin actions written to PostgreSQL admin_audit_log (immutable, no deletes)
  • Risk alerts: audible + visual (browser Notification API) — browser tab title shows alert count
API & Data Flow
🔌 API — Live Map
  • WS /ws/admin/feed — events: trip_update, driver_position, kpi_snapshot, anomaly_alert
  • GET /admin/trips/active?city=Kampala&service_type=&status= — initial page load
  • GET /admin/drivers/online?city=Kampala — driver list with positions
  • GET /admin/kpis?window=30m → { active_trips, online_drivers, avg_wait_min, open_alerts }
  • GET /admin/trips/{trip_id} → full trip context for side panel
  • Kafka: trip.status.changed + driver.position → Admin Feed Service → WebSocket broadcast
Edge Cases & Implementation Notes
⚙️ Implementation Notes
  • WS reconnect on admin browser tab sleep (Chrome throttles background tabs) — use visibility API
  • High trip volume (> 500 concurrent): cluster map markers, expand on zoom — Leaflet MarkerCluster
  • Alert storms: batch multiple anomalies on same trip into single alert card (dedup by trip_id)
  • Multi-city: city selector in header — WS subscription filtered server-side by city scope
  • Admin session timeout: 4-hour JWT expiry with refresh; force re-login for security
  • Leakage prevention: admin endpoints require admin:read scope — not accessible with passenger/driver tokens
⚠️ Fraud Console
Open alerts sorted by severity · Auto-refreshes every 30 s
Open Alerts
14
↑ +3 last hour
Critical
2
⚡ Immediate action
Accounts Frozen
5
Today
False Positive Rate
3.2%
↓ Improving
Alert Queue14 open
IDTypeUserScoreSeverityActions
FA-2241GPS SpoofUBD-123X0.94Critical
FA-2240Cancel AbuseSsali J.0.81High
FA-2239Promo Abuse+256-709…0.72High
FA-2238Device ReuseNakato A.0.65Medium
FA-2237Route DeviationUBD-456Y0.58Medium
Case Timeline — FA-2241Critical
!
GPS spoof detected
Impossible speed jump: 0→120 km/h in 3 s. Trip #1025, Katwe. Risk score: 0.94
09:42:18 AM
i
Payment collected before trip start
MoMo push UGX 12,400 settled 2 min before driver marked trip started
09:40:05 AM
Same device — 3rd account this week
Device fingerprint matches 2 previously suspended accounts
09:38:50 AM
KYC passed (auto)
National ID verified — Nov 2023
Nov 12, 2023
Component
⚠️ Fraud Console Screen 2
  • Alert queue sorted by ML confidence score (0–1) then severity: Critical → High → Medium → Low
  • One-click actions per alert: Freeze (suspend account), Warn (send notice), Dismiss, Escalate to senior
  • Severity badge color: red=Critical (>0.9), orange=High (0.7–0.9), amber=Medium (0.5–0.7)
  • Case timeline: chronological audit of GPS data, payment logs, device fingerprint, and prior flags
  • Bulk actions: select multiple medium-risk alerts and bulk-dismiss after review
  • False positive rate KPI tracked — Fraud team reviews dismissed cases to retrain ML model
Architectural Decisions
🏗️ Architecture — Fraud Detection
  • Fraud Service: hybrid rules engine (deterministic) + gradient-boosted ML model (probabilistic)
  • Rules engine (instant): GPS speed jump > 100 km/h, payment before trip start, cancel abuse pattern
  • ML model (async, ~500ms): XGBoost trained on labeled fraud cases; features: speed, route deviation, device, timing
  • Device fingerprinting: canvas hash + screen resolution + user-agent — stored hashed in Redis
  • Promo abuse: velocity limiter — max 1 first-trip promo per unique MSISDN+device combo per 30 days
  • Kafka: trip.position + payment.completed → Fraud Service → fraud.alert.raised topic → Admin dashboard
  • All fraud actions are immutable audit events — no hard deletes; GDPR export on request
API & Data Flow
🔌 API — Fraud Console
  • GET /admin/fraud/alerts?severity=&status=open&page= → paginated alert list
  • GET /admin/fraud/alerts/{alert_id} → full case: timeline events, trip context, device info
  • POST /admin/fraud/alerts/{alert_id}/action { action: "freeze"|"warn"|"dismiss"|"escalate", note? }
  • GET /admin/fraud/kpis → { open_alerts, critical, frozen_accounts, false_positive_rate }
  • POST /admin/fraud/bulk-action { alert_ids[], action } — bulk dismiss/warn
  • Kafka: fraud.alert.raised → Admin Feed WS → browser alert banner + queue update
Edge Cases & Implementation Notes
⚙️ Implementation Notes
  • GPS spoof common in Uganda: motos run mapping apps that emit fake GPS — tune speed threshold carefully
  • Account freeze impact: driver loses income → freeze only on Critical; Warn on High first
  • Appeal flow: frozen driver can submit appeal via Driver App → Ops queue with 24-hr SLA
  • ML retraining: weekly batch job on labeled cases (dismissed=negative, actioned=positive) — MLflow tracking
  • New fraud pattern: Ops can add rule via Admin config UI without code deploy
  • Shared device families (multiple drivers share one phone): whitelist mechanism in device fingerprint DB
⚡ Pricing Engine — Live
Autonomous fare decisions per zone · explainability · price-shock guard
Quotes / min
182
↑ Peak hour
Avg surge (city)
1.18×
↑ vs 1.05× off-peak
Decision latency
42 ms
↓ Healthy < 100ms
Price-shock blocks
2
Today · auto-smoothed
Live signals being ingested ● 6 / 6 healthy
⛽ Fuel · NITA-U index
UGX 5,420 /L
Updated 6 h ago · weekly
💱 FX · USD/UGX
UGX 3,810
Updated 5 min ago · BoU
🚦 Traffic · Google DM
+18% above free-flow
Real-time
📈 Demand · last 5 min
142 quotes
Wandegeya peak
🌧 Weather · OpenWeatherMap
Light rain
+0.05 demand wt.
📅 Time-of-day · Calendar
Rush hour 17:00–19:00
Mon · workday
Live formula in effectv 2026.05.02-rc1
fare = ( base + dist × k_d + time × k_t ) × surge + fuel_adj promo where surge = clamp( 1 + 0.40×demand + 0.30×traffic + 0.10×weather, 1.00, 1.80 ) fuel_adj = f( fuel_index_delta_pct ) · bounded ±15% of base fx_adj = applied to UGX-USD>5% weekly drift only // price-shock guard if ( new_fare / avg_recent > 1.20 ) apply EMA smoothing
Every coefficient and clamp shown above is configurable in Pricing Control with audit trail. Driver minimum (UGX 800/km) enforced post-formula.
Quote explainability — TXN-44218UGX 9,200
Acacia Mall → Nakasero · 4.2 km · 17:42 · Boda
🏁
Base + distance + time
3,000 + 4.2km × 800 + traffic 4 min × 80
UGX 6,680
📈
Surge × 1.30
demand 0.62 + traffic 0.45 + weather 0.10
+ UGX 2,004
Fuel adjustment
fuel up 4.2% this week vs 30-day avg
+ UGX 516
💱
FX adjustment
UGX/USD drift 1.2% — below 5% threshold
UGX 0
🎁
Promo
no active promo for this user
UGX 0
Final fare to passenger
Driver gets 86% (UGX 7,912 net of 14% commission)
UGX 9,200
Per-zone autonomous decisions — last 5 min ● Live
ZoneDemandTrafficSurgeAvg fareQuotesDecision
Wandegeya0.78+22%1.45×UGX 9,84034Peak surge
City Centre0.62+18%1.30×UGX 8,42042Surge
Kololo0.34+8%1.05×UGX 7,10018Normal
Ntinda0.21+4%1.00×UGX 6,80012Normal
Bugolobi0.45+12%1.18×UGX 7,65022Mild surge
Katwe0.28+15%1.08×UGX 6,92014Normal
Component
⚡ Pricing Engine Screen 7
  • Live ops view — Twende's pricing differentiator made visible
  • Six signal cards: fuel index, FX rate, traffic, demand, weather, time-of-day — all status badges
  • Live formula displayed with current coefficients — full transparency, no black box
  • Quote explainability: any quote can be traced line-by-line back to signals + formula
  • Per-zone autonomous decisions table — operators see the engine working in real time
  • Price-shock blocks counter — engine self-throttles when fares would jump >20% (smoothing)
  • Pause-button kill-switch lets ops fall back to last manual config in < 5 s
Architectural Decisions
🏗️ Architecture — Pricing Intelligence
  • Pricing Service: stateless, p99 < 100 ms — formula evaluated per quote request
  • Signal aggregator: Kafka consumers fold raw events into rolling features (demand_score, traffic_score, weather_score) → Redis hot cache
  • Fuel index: NITA-U weekly API + scrape fallback (TotalEnergies, Shell, Stabex prices) — diff vs 30-day moving avg drives fuel_adj
  • FX rate: Bank of Uganda mid-rate API every 5 min — applied only when 7-day drift > 5% (avoids reactive thrashing)
  • Traffic: Google Distance Matrix API — actual_time / free_flow_time ratio per H3 zone
  • Weather: OpenWeatherMap city-level — rain/storms increment demand weight (passenger reluctance)
  • All coefficients in PostgreSQL pricing_config — versioned, every change Kafka-broadcast → instant replicas reload
  • Decision log: every autonomous decision stored in ClickHouse for audit + ML retraining
  • Price-shock guard: EMA smoothing prevents dramatic single-quote jumps (passenger trust)
API & Data Flow
🔌 API — Pricing Engine
  • POST /trips/quote (existing) → server invokes Pricing Service which composes the formula
  • GET /admin/pricing-engine/signals → live snapshot of all 6 signals + freshness status
  • GET /admin/pricing-engine/explain/{quote_token} → line-by-line breakdown for any quote
  • GET /admin/pricing-engine/decisions?window=5m → per-zone surge decisions
  • POST /admin/pricing-engine/pause { reason, duration_min } — kill-switch, requires SuperAdmin
  • WS /ws/admin/pricing-engine — push updates of signal changes + decisions for live dashboards
  • Kafka topics: fuel.update, fx.update, traffic.update, demand.snapshot, weather.snapshot, pricing.decision
Edge Cases & Implementation Notes
⚙️ Implementation Notes
  • Signal source down (e.g. Google traffic API): engine falls back to last-known + degraded confidence flag
  • Fuel API stale > 14 days: alert SecOps + freeze fuel_adj at last value (don\'t guess)
  • FX shock (e.g. UGX devaluation 10% in a day): engine pauses auto-FX adj + alerts SuperAdmin for manual review
  • Holiday/event (Independence Day, election): admin can flag known events to override demand model
  • Multi-currency expansion (KES, RWF future): each currency has own fuel + FX feed; same engine, regional configs
  • Audit: pricing decisions immutable in ClickHouse; legal can replay any specific quote on demand
  • Retraining: ML demand model uses actual booked-vs-quoted gap to refine demand_score weighting weekly
  • Driver minimum floor (UGX 800/km) ALWAYS enforced — even if formula yields lower, system bumps to floor
  • Anti-bypass: high surge (> 1.5×) shown to passenger with explanation ("Rain + rush hour") — managed expectation
⛽ Fuel & FX Data Feeds
Source health · historical trends · manual overrides for fuel and currency signals
Fuel · NITA-U Index (Petrol) Healthy
UGX 5,420 /litre ↑ +4.2% wk
30-day avgUGX 5,200
SourceNITA-U weekly API
Last updated6 h ago · auto
FallbackScrape Total / Shell / Stabex
Applied to fuel_adj+UGX 700 / trip avg
💱 FX · USD/UGX (Bank of Uganda mid) Healthy
3,810 UGX per USD ↑ +1.2% wk
30-day avg3,765
SourceBoU API (5-min poll)
Threshold5% drift triggers fx_adj
StatusBelow threshold · no adj applied
Last shock eventNov 2024 · +8.4% in 2 wks
Fuel · Diesel (for car drivers) Healthy
UGX 5,180 /litre ↑ +3.8% wk
Used forCar / cargo trips only
Last updated6 h ago · auto
💱 FX · KES/UGX (East Africa expansion) Inactive
28.4 UGX per KES
StatusTracked, not yet applied
Activates whenTwende Kenya launch
Recent fuel/FX events & engine actions
i
Fuel index updated · NITA-U weekly
Petrol +4.2% week-on-week · automatically increased fuel_adj from UGX 500 to UGX 700 across all boda quotes.
Today 03:14
FX drift approaching threshold
USD/UGX 7-day drift at 4.1% · 5% threshold triggers automatic fare-FX adjustment. Monitoring.
Yesterday 16:42
Manual fuel override applied · Stella Atim
Stations reported fuel shortage in Wakiso · raised fuel_adj cap to UGX 1,200 for that zone temporarily.
2 days ago
!
NITA-U API timeout > 30 min
Source unavailable · failed over to scrape (TotalEnergies/Shell/Stabex). Restored automatically after 47 min.
5 days ago
Component
⛽ Fuel & FX Feeds Screen 8
  • One card per data feed: fuel (petrol + diesel), FX (USD/UGX, KES/UGX, etc.)
  • Mini-chart shows 30-day trend at a glance — spot anomalies
  • Threshold logic surfaced explicitly (e.g. "5% drift triggers fx_adj")
  • Manual override CTA — for known shocks (fuel shortage, holiday, election)
  • Audit timeline at bottom — every fuel/FX event + engine response
  • Sources marked Healthy / Stale / Error / Inactive — operations confidence at a glance
Architectural Decisions
🏗️ Architecture — Data Feeds
  • Each feed = scheduled Airflow DAG → writes to PostgreSQL data_feeds table → publishes Kafka {fuel|fx}.update event
  • Pricing Service consumes Kafka topics → updates in-memory state within seconds of publish
  • Multiple sources per data type: primary + 1–2 fallbacks (NITA-U → Total scrape → Shell scrape)
  • Source health: ping check every 5 min — degraded sources marked, fallback auto-promoted
  • Manual override creates a time-bounded entry in fuel_overrides / fx_overrides — auto-expires
  • FX threshold (5% drift) prevents reactive fare changes from minor daily fluctuations
  • Multi-region ready: data_feeds rows scoped by country_code for KES, RWF, etc.
API & Data Flow
🔌 API — Data Feeds
  • GET /admin/data-feeds → all feeds with status, current value, 30-day series
  • GET /admin/data-feeds/{feed_id}/history?window=30d → time-series data
  • POST /admin/data-feeds/{feed_id}/override { value, reason, expires_at }
  • POST /admin/data-feeds/{feed_id}/refresh → manual fetch trigger
  • GET /admin/data-feeds/events → audit timeline of feed updates and overrides
  • Kafka: fuel.update + fx.update → Pricing Service consumer fan-out
Edge Cases & Implementation Notes
⚙️ Implementation Notes
  • NITA-U is the official source but lags — diff against scraped retail prices catches discrepancies
  • Fuel shortage / hoarding (rare in Kampala): manual override + per-zone fuel_adj cap
  • FX volatility on policy days (BoU MPC announcements): engine auto-pauses FX adj for 24 h after big move
  • Cross-country expansion: each country gets own feed registry; UI scopes by country selector
  • Data feed compromise (vendor changes API contract): schema validation on ingest + alert on parse failure
  • Stale data > 24 h: engine drops the contribution from formula entirely (zero, not last-known)
  • Manual override audit-logged + requires SuperAdmin scope (price impact is significant)
💰 Pricing Control
Kampala · Boda · Effective changes publish in real-time
Current Surge
1.3×
↑ Demand peak
Max Allowed Surge
1.8×
Regulatory cap
Base Fare (Boda)
UGX 3,000
Unchanged
Driver Min/km
UGX 800
Floor enforced
Surge Multiplier Controls
Surge Cap 1.8×
1.0× (base)3.0× (max)
Demand Weight (a) 0.4
0.01.0
Traffic Weight (b) 0.3
0.01.0
Fuel Adjustment (max) UGX 800
UGX 0UGX 2,000
Live Fare SimulatorPreview

Showing estimated fares with current settings for sample trips:

RouteDistBaseSurgeTotalStatus
Acacia → Nakasero4.2 km7,5001.3×9,750Surge
Wandegeya → Uni2.1 km4,2001.0×4,200Normal
Ntinda → City7.8 km11,4001.3×14,820Surge
Muyenga → Bugolobi3.5 km6,0001.0×6,000Normal

📌 Price shock guard: if fare rises >20% vs previous trip, EMA smoothing applied.

Component
💰 Pricing Controls Screen 3
  • Slider controls for: surge cap multiplier, demand weight (α), traffic weight (β), fuel adjustment (±%)
  • Hard regulatory cap enforced at 1.8× — Uganda pricing fairness policy; slider cannot exceed it
  • Driver earnings floor: minimum per-km rate configurable — prevents exploitative fares
  • Fare simulator: preview impact on sample routes before publishing any change
  • Changes require explicit "Publish" step — with confirmation dialog listing affected cities
  • Audit log: every pricing change attributed to admin user with before/after values
Architectural Decisions
🏗️ Architecture — Pricing Engine
  • Pricing formula: Fare = (base + dist×k_d + time×k_t) × clamp(surge, 1.0, cap) + fuel_adj − promo
  • Surge = 1 + α×demand_score + β×traffic_score (demand from ClickHouse, traffic from Google Maps API)
  • Fuel adjustment: indexed weekly from NITA-U fuel price API; bounded ±15% of base to prevent shock
  • Pricing Service: stateless microservice reads config from PostgreSQL pricing_config table (cached in Redis)
  • Config change → Kafka "pricing.config.updated" event → all Pricing Service replicas reload within 5 s
  • Fare simulator runs same Pricing Service logic in "preview mode" — no Kafka events emitted
  • Price shock guard: if proposed change raises avg fare > 20%, require SuperAdmin approval
API & Data Flow
🔌 API — Pricing Controls
  • GET /admin/pricing/config?city=Kampala → { base_ugx, k_d, k_t, surge_cap, fuel_adj_pct, floor_ugx_per_km }
  • POST /admin/pricing/simulate { config_draft, sample_trips[] } → [{ route, fare_normal, fare_surge, status }]
  • PUT /admin/pricing/config { city, ...params } — draft save (not live yet)
  • POST /admin/pricing/publish { city, config_id, note } → publishes to all Pricing Service replicas
  • GET /admin/pricing/history?city= → audit log of all config changes with diff
  • Kafka: pricing.config.updated → Pricing Service reload → immediate effect on new quote requests
Edge Cases & Implementation Notes
⚙️ Implementation Notes
  • Fuel price shocks (common in Uganda): automated weekly update + manual override capability
  • Rollback: one-click revert to previous config version in history — critical for production incidents
  • Multi-city pricing: Kampala vs Jinja vs Mbarara can have different base fares and fuel indices
  • Driver bypass risk: if pricing is unfair, drivers take rides off-app — monitor off-app rate in analytics
  • Holiday/event surge: manual temporary surge config for known events (concerts, elections) with auto-expiry
  • Transparency: pricing formula shown to passengers in fare quote — admin changes must update UI copy too
📋 Disputes Center
Open cases · SLA tracked · Assignee-based workflow
Open Cases
7
Total open
Overdue (SLA)
2
⚡ Action needed
Avg Resolution
4.2h
↓ -0.5h this week
Refunds Issued
UGX 124k
Today
Case Queue
DSP-0041· Overcharge claim⏰ Overdue
AAmina — Unassigned
DSP-0040· Driver no-show4h left
BByarugaba, Ivan
DSP-0039· MoMo charged, no ride⏰ Overdue
NNakato, Sarah
DSP-0038· Safety complaint12h left
OOdong, Patrick
DSP-0037· Wrong route taken18h left
AAmina
DSP-0041 — Overcharge Claim
Passenger reported: fare UGX 14,200 vs quoted UGX 9,200
Trip#1021 · Apr 29, 2024 08:55 AM
PassengerSarah Namukasa · ⭐ 4.7
DriverKato Moses · UBD 123X · ⭐ 4.8
Quoted FareUGX 9,200
ChargedUGX 14,200 (+54%)
StatusSLA Overdue
Comms Log
[08:59] Passenger raised dispute via in-app.
[09:02] Auto-acknowledgment sent to passenger.
[09:15] Case auto-assigned to Amina (queue overflow).
[10:40] Fare breakdown retrieved from pricing service.
Component
📋 Disputes Queue Screen 4
  • Disputes queue sorted by SLA urgency: overdue (red) → due soon (amber) → on-track (green)
  • Each case shows type (overcharge, safety incident, driver no-show, payment failure), passenger, assignee
  • SLA countdown timer shown in real-time — overdue cases auto-escalate to team lead
  • Case detail panel: full trip receipt, quoted vs actual fare diff, comms log, resolution actions
  • Actions: Refund (partial/full), Resolve (no action), Escalate, Message (in-app + SMS)
  • Resolution history: all actions immutably logged with agent name, timestamp, and outcome
Architectural Decisions
🏗️ Architecture — Disputes
  • Dispute Service: separate microservice — handles ticketing workflow independent of Trip/Payment
  • SLA timers: Redis sorted sets with expiry scores — Dispute Service polls every 60 s for overdue cases
  • Refunds: Dispute Service calls Payment Service refund endpoint → MoMo reverse transaction
  • Auto-escalation: Kafka "dispute.sla.breached" event → Ops lead FCM push + Slack notification
  • Comms log: each message stored in PostgreSQL dispute_messages (append-only, no edits)
  • Fraud-linked disputes (e.g. GPS spoof overcharge): automatically linked to Fraud Service case
  • Audit trail: every action attributed to admin user ID — required for regulatory compliance in Uganda
API & Data Flow
🔌 API — Disputes
  • GET /admin/disputes?status=open&type=&assignee=&page= → paginated dispute list with SLA status
  • GET /admin/disputes/{dispute_id} → full case: trip, fare_breakdown, comms_log, actions_available
  • POST /admin/disputes/{dispute_id}/refund { amount_ugx, reason } → Payment Service refund call
  • POST /admin/disputes/{dispute_id}/resolve { outcome: "resolved"|"escalated", note }
  • POST /admin/disputes/{dispute_id}/message { channel: "in_app"|"sms", text } → Comms Service
  • GET /admin/disputes/kpis → { open, overdue, avg_resolution_hours, refunds_today_ugx }
Edge Cases & Implementation Notes
⚙️ Implementation Notes
  • Standard SLA: 24 h resolution; Safety/fraud cases: 4 h SLA; auto-escalate if unassigned > 2 h
  • Partial refund: common for "driver took wrong route" — refund the distance overage only
  • Refund via cash trips: no payment to reverse — Twende adds credit to passenger wallet instead
  • High-volume dispute spikes: batch import to dispute queue with auto-triage by type and severity
  • Passenger unreachable: dispute auto-resolves after 72 h if passenger doesn't respond to Ops messages
  • Regulatory requirement: maintain dispute records for 5 years (Uganda Consumer Protection Act)
🔒 KYC Review Queue
Driver onboarding documents pending verification · SLA 48 h
Pending Review
12
↑ +3 today
SLA Breached
2
⚡ > 48 h
Approved Today
28
↑ +6 vs avg
Auto-rejected
4
Bad photos / mismatch
Application Queue12 pending
DriverPhoneVehicleSubmittedSLAAction
Kato Moses+256 772 ••• 891🏍️ Boda · UBD 123X2 h agoOn track
Sarah Namutebi+256 700 ••• 234🚗 Car · UAB 456Z6 h agoOn track
James Ssali+256 752 ••• 105🏍️ Boda · UBC 789Y1 d ago14 h left
Patrick Odong+256 754 ••• 567🚗 Car · UAA 234X2 d ago⏰ Overdue
Amina Nakato+256 758 ••• 902🏍️ Boda · UBE 567Q3 d ago⏰ Overdue
Application Detail — Kato MosesIn review
DriverKato Moses · 28
National IDCM••••••XYZ4 · matched ✓
Selfie biometricMatch score: 0.97
DL expiry15 Aug 2026 · valid
Police clearanceIssued 14 Apr 2026 · clear
Vehicle logbookOwner: Kato M. · plate UBD 123X ✓
Device fingerprintNo prior account on this device
📄 DL front
🛂 NIN
🤳 Selfie
Component
🔒 KYC Review Queue Screen 5
  • Queue sorted by SLA breach risk: overdue → due soon → on-track
  • Detail panel shows all docs side-by-side: NIN, DL, selfie, vehicle logbook, police clearance
  • Smile ID biometric match score visible — auto-flagged if score < 0.85
  • Actions: Approve / Request more info (with reason) / Reject (with code)
  • Bulk action: select multiple low-risk applications for batch approval after review
  • Device fingerprint shown — flags if device has been used by previously-rejected applicant
Architectural Decisions
🏗️ Architecture — KYC Review
  • KYC Service queues new applications to Admin Review queue (PostgreSQL kyc_applications)
  • Auto-reject path: Smile ID match < 0.6, NIN not found, or duplicate device with prior rejection
  • Manual queue: applications scoring 0.6–0.85 OR with edge-case flags (rural plate, expired police)
  • Reviewer assignment: round-robin across active reviewers; load balanced
  • SLA: 48 h standard, 24 h for high-tier referrals — auto-escalates to Ops lead on breach
  • Decisions trigger Auth Service to flip kyc_status; Driver Service unlocks ride acceptance
  • Audit immutability: every approve/reject/info-request stored in kyc_decisions append-only
API & Data Flow
🔌 API — KYC Review
  • GET /admin/kyc/queue?status=pending&sla=&reviewer= → paginated applications
  • GET /admin/kyc/{driver_id} → docs (signed S3 URLs), biometric scores, prior history
  • POST /admin/kyc/{driver_id}/decision { action: "approve"|"reject"|"request_info", reason_code, note }
  • POST /admin/kyc/bulk-decision { driver_ids[], action } — bulk approve only
  • GET /admin/kyc/kpis → { pending, sla_breached, approved_today, auto_rejected }
  • Kafka: kyc.decision.made → Driver Service (unlock/lock acceptance), Auth Service (flip status)
Edge Cases & Implementation Notes
⚙️ Implementation Notes
  • NIN not in NIRA database (rural applicants): manual review path with extended SLA
  • Photo quality issues: re-upload requested with examples; 3 strikes → manual call follow-up
  • Reviewer fatigue: max 50 reviews per shift to maintain decision quality (analytics-tracked)
  • Appeals: rejected drivers can submit appeal once → re-routed to senior reviewer
  • Reviewer bias monitoring: monthly audit of approve/reject ratios per reviewer for fairness
  • Document expiry warnings auto-triggered 30 days before — driver must re-submit before expiry
🏍️ Vehicle Compliance
Plate verification failures, vehicle change requests, and accident-related suspensions
Active mismatches
3
⚡ Driver auto-suspended
Pending change requests
8
Avg review 36 h
Pre-shift check pass rate
99.2%
↑ +0.4% this week
Passenger reports / 1k trips
0.8
↓ Industry-leading
Active Vehicle Issues11 open
DriverIssueDetected byRegistered → ActualStatusAction
Ssali J.Plate mismatchPre-shift OCRUBD 123X → UBC 456YAuto-suspended
Odong P.Passenger reportPickup verifyUAB 789Z → unknownAuto-suspended
Nakato A.Color mismatchPassenger reportRed Bajaj → Blue BajajUnder review
Mukasa K.Change requestDriver self-reportBajaj → Honda (accident)Docs pending
Tumusiime J.Insurance lapsedAuto-detectInsurance expired 2 MaySuspended
Case Detail — Ssali J. (UBD 123X)Critical
Registered
UBD 123X
🏍️ Bajaj Boxer · Red
Photographed today
UBC 456Y
🏍️ Bajaj Boxer · Red
DetectedPre-shift OCR · 09:14 today · confidence 0.94
Driver KYCActive · ⭐ 4.7 · 92 trips · no prior incidents
Last legitimate changeNone · vehicle has been UBD 123X since signup
Driver explanation⚠️ Not yet provided
🏍️
Reg front
🏍️
Reg back
📸
Today
🆔
NIN
📋
Logbook
💡 Likely scenarios: (1) accident — original vehicle damaged; (2) sale — driver bought new bike; (3) borrowing — using cousin's bike; (4) fraud — account-jumping.
Component
🏍️ Vehicle Compliance Screen 10
  • Single console for all vehicle-identity issues: pre-shift mismatches, passenger reports, change requests, accident suspensions
  • KPI: pre-shift pass rate (99.2% target) + passenger reports per 1k trips (industry safety benchmark)
  • Side-by-side comparison panel for plate / vehicle / driver — instant visual diff
  • Photo strip surfaces all evidence: registered shots, today's photo, KYC docs, logbook
  • Likely-scenario hint helps reviewer prioritize next action (call driver vs immediate ban)
  • Three primary actions: call driver / trigger re-KYC / permanent ban — proportional response
Architectural Decisions
🏗️ Architecture — Vehicle Trust
  • Vehicle Compliance Service consumes 4 event streams: pre-shift OCR results, passenger pickup-verify reports, driver change requests, doc expiry alerts
  • Auto-suspension triggers (Driver Service flips can_accept_rides=false): plate OCR mismatch (any), passenger mismatch report (any), insurance/logbook expired
  • Auto-ban triggers (escalated): 3rd plate mismatch in 30 days, fraud-confirmed change pattern
  • Compliance Service has read access to KYC photos for side-by-side comparison
  • SLA: high-severity cases (plate mismatch + passenger report) resolved < 4 h; standard change < 48 h
  • All decisions audit-logged + reasoning preserved (5-yr retention)
  • ML model trained on resolved cases — predicts likely scenario (accident vs sale vs fraud) with 87% accuracy
API & Data Flow
🔌 API — Vehicle Compliance
  • GET /admin/vehicle-compliance/cases?status=open&severity= → paginated cases
  • GET /admin/vehicle-compliance/cases/{case_id} → full context: registered photos, today\'s photo, OCR results, KYC, history
  • POST /admin/vehicle-compliance/cases/{case_id}/action { action: "call"|"reKYC"|"ban"|"approve_change", reason, evidence_doc_id? }
  • GET /admin/vehicle-compliance/kpis → { mismatches_open, change_requests, preshift_pass_rate, reports_per_1k_trips }
  • Kafka: vehicle.preshift_mismatch + trip.pickup_mismatch_reported → Vehicle Compliance Service (case creation)
  • Kafka: vehicle.compliance.action_taken → Driver Service (status update), Audit Log
Edge Cases & Implementation Notes
⚙️ Implementation Notes
  • Driver explanation often crucial: legitimate accidents (with police report) easily resolved; silence + mismatch → likely fraud
  • Boda fleet operators: one logbook owner, multiple drivers — handled via "borrow" flow with consent + per-driver insurance
  • Repeat offenders shared across operators: industry fraud DB integrated (future) — Tugende, SafeBoda alignment
  • False positive: dirty plate / OCR error — driver appeals via re-photo + Ops re-verify (no penalty)
  • Driver claims phone broken: cannot complete pre-shift check — manual override allowed (1×, audit-logged)
  • Geographic context: rural drivers may have legitimate plate-replacement delays (parts shortage) — extended grace
  • Insurance auto-suspend integrated with NITA-U / Uganda Insurance Regulatory Authority API (when available)
👤 User Management
Search, view, freeze, or escalate any passenger or driver account
Active Passengers
8,412
↑ +124 this week
Active Drivers
412
↑ +8
Suspended
23
Fraud / KYC fail
Admin Users
14
RBAC roles
All Users
IDNamePhoneRoleStatusTripsJoinedActions
U-1042Sarah Namukasa+256 712 ••• 678PassengerActive47Jan 2024
U-1043Kato Moses+256 772 ••• 891DriverActive218Mar 2024
U-1044James Ssali+256 752 ••• 105DriverSuspended92Feb 2024
U-1045Amina Nakato+256 758 ••• 902DriverPending KYC03 d ago
U-A001Patrick Byarugaba(internal)Admin · OpsActiveNov 2023
U-A002Stella Atim(internal)Admin · SuperAdminActiveOct 2023
Component
👤 User Management Screen 6
  • Unified directory: passengers, drivers, admin users — single search box (phone, name, ID)
  • Role-based filter chips: All / Passengers / Drivers / Admins / Suspended
  • Status badge: Active (green), Pending KYC (amber), Suspended (red)
  • Tap row → user detail with full trip / payment / dispute / fraud history
  • Freeze / unfreeze account, force re-KYC, reset device, change role from detail view
  • Export to CSV — for compliance reports + executive dashboards
Architectural Decisions
🏗️ Architecture — User Mgmt
  • Unified search: Elasticsearch index on users + drivers tables — fast phone/name/ID lookup
  • Detail page aggregates from Trip, Payment, Dispute, Fraud, KYC services via Admin BFF
  • RBAC strictly enforced: Ops can view, Fraud Lead can freeze, only SuperAdmin can change roles
  • Audit log: every admin action on a user account → admin_audit_log (immutable)
  • PII display: phones masked by default; full reveal requires Sensitive scope + reason note
  • Bulk operations limited (max 50 at a time) to prevent accidental mass actions
API & Data Flow
🔌 API — User Mgmt
  • GET /admin/users?q=&role=&status=&page= → paginated user list
  • GET /admin/users/{user_id} → aggregated profile (trips, payments, disputes, fraud alerts)
  • POST /admin/users/{user_id}/freeze { reason, expires_at? } → triggers Auth lockout
  • POST /admin/users/{user_id}/unfreeze { note }
  • POST /admin/users/{user_id}/reveal-pii { reason } — full phone + ID number, audit-logged
  • POST /admin/users/{user_id}/role { role: "ops"|"fraud_analyst"|... } — SuperAdmin only
Edge Cases & Implementation Notes
⚙️ Implementation Notes
  • Phone number reuse (SIM swap, second-hand SIMs): account history tied to user_id, not phone
  • Account merge: rare but supported via SuperAdmin tool (e.g. duplicate signups)
  • Self-destruct prevention: SuperAdmin cannot freeze own account (force 2-person rule)
  • Bulk freeze: gated behind 2FA confirmation + reason (anti-mass-griefing safeguard)
  • Right-to-be-forgotten: 30-day soft-delete, then full PII purge (Uganda Data Protection Act)
  • Search performance: Elasticsearch reindexed nightly; near-real-time updates via Kafka consumer
💳 Payments Reconciliation
Daily MoMo / Airtel settlement vs internal ledger · break detection
Settled today
UGX 14.8M
↑ +12% vs yesterday
Open breaks
3
UGX 18,400
Pending payouts
UGX 1.2M
412 drivers
Auto-matched
99.4%
↑ Healthy
Daily Settlement vs Ledger3 breaks
DateOperatorOperator totalLedger totalDiffStatus
2 MayMTN MoMoUGX 9,840,200UGX 9,840,200UGX 0Matched
2 MayAirtel MoneyUGX 4,950,400UGX 4,932,000+UGX 18,400Break
1 MayMTN MoMoUGX 8,200,800UGX 8,200,800UGX 0Matched
1 MayAirtel MoneyUGX 3,840,000UGX 3,840,000UGX 0Matched
30 AprCash tripsUGX 612,000Self-attested
Break — Airtel 2 MayUGX 18,400
!
3 ledger entries with no operator match
TXN-44102, TXN-44108, TXN-44119 — total UGX 18,400
Auto-detected 03:15
Likely cause: Airtel API delay
Operator confirmation typically arrives within 30 min — these are 6 h late
03:45
i
Recommended action
Hold for 24 h. If still unmatched, manually credit drivers and write off as operator-side loss (claim from Airtel).
Component
💳 Reconciliation Screen 7
  • Daily diff between operator settlement files (MoMo, Airtel) and internal ledger
  • Auto-match by operator transaction ref + amount + timestamp
  • Breaks surfaced individually — case timeline shows likely cause and recommended action
  • Cash trips listed separately — self-attested by drivers, no operator counterparty
  • One-click resolution: hold, credit drivers, or escalate to operator
Architectural Decisions
🏗️ Architecture — Reconciliation
  • Reconciliation job: nightly Spark/Airflow DAG matches operator CSVs against PostgreSQL ledger
  • Match rules: exact (ref+amount), tolerant (amount within 1 UGX rounding, ref within 1 min)
  • Breaks stored in recon_breaks table with cause hypothesis (rules-based)
  • Operator file ingestion: SFTP poll for MTN, REST API pull for Airtel — encrypted at rest in S3
  • Ledger immutability preserved — corrections done via new offsetting entries, never row updates
  • Finance audit: 5-yr retention of all reconciliations + breaks (regulatory requirement)
API & Data Flow
🔌 API — Reconciliation
  • GET /admin/recon/runs?date= → daily reconciliation summary
  • GET /admin/recon/breaks?status=open → list of unmatched transactions
  • POST /admin/recon/breaks/{id}/resolve { action: "hold"|"credit"|"escalate", note }
  • POST /admin/recon/run-now { date } → trigger ad-hoc reconciliation
  • GET /admin/recon/kpis → settled, breaks_open, pending_payouts, auto_match_rate
Edge Cases & Implementation Notes
⚙️ Implementation Notes
  • MoMo settlement files arrive ~03:00 — recon runs 03:15 to give some buffer
  • Airtel API rate-limited — recon retries with exponential backoff up to 3 attempts
  • Operator-side loss (rare): we credit drivers, then file claim with operator (Twende absorbs short-term)
  • Cash trips: driver self-attests via app, audit by sampling + rider rating signal
  • Multi-day break carry-over: cases tracked across days until resolved or written off
  • Anti-fraud: any single break > UGX 50k auto-escalates to Finance lead
📊 Analytics & Demand Forecast
Trip volumes, demand trends, and 30-min demand forecast by zone
Trips today
1,842
↑ +14% vs avg
GMV today
UGX 16.4M
↑ +18%
New riders
124
↑ +22
Avg fare
UGX 8,920
↑ +UGX 240
Trips by hour — last 24 hLive
200 150 100 50 00 06 12 18
Actual trips 30-min forecast
Demand forecast — next 30 min
ZoneNow+30 minΔ
Wandegeya1422+57%
City Centre1820+11%
Kololo910+11%
Ntinda128−33%
Bugolobi67+17%
💡 Wandegeya forecast suggests imminent surge — consider pre-positioning incentives.
Component
📊 Analytics & Forecast Screen 8
  • Top KPIs: trips, GMV, new riders, avg fare with day-over-day deltas
  • 24-hour trip chart with 30-min forward-looking forecast (dashed extension)
  • Per-zone demand forecast table — flags imminent surges
  • Pre-built reports: weekly executive summary, driver supply/demand mismatch, fraud trends
  • Date range picker + export to CSV / PDF for board reporting
Architectural Decisions
🏗️ Architecture — Analytics
  • ClickHouse for OLAP: pre-aggregated rollups (hourly, daily, weekly) — sub-second dashboards
  • Demand forecast: Prophet model + zone features — refreshed hourly via Airflow
  • Forecast confidence intervals stored — UI shows ±range when uncertainty high
  • Kafka consumers feed ClickHouse continuously — near-real-time KPIs
  • Pre-built dashboards via Metabase embedded; custom queries via internal Superset
  • RBAC: Ops sees city-level, Finance sees GMV, SuperAdmin sees everything + forecast model
API & Data Flow
🔌 API — Analytics
  • GET /admin/analytics/kpis?from=&to=&city= → headline metrics
  • GET /admin/analytics/trips/by-hour?date= → time-series for hourly chart
  • GET /admin/forecast/demand?zone=&horizon_min=30 → predicted score + confidence
  • GET /admin/analytics/exports?type=weekly_summary&date= → S3 PDF/CSV link
  • WebSocket /ws/admin/kpis — live KPI updates every 30 s
Edge Cases & Implementation Notes
⚙️ Implementation Notes
  • Forecast cold-start: new zones with low data show wide confidence band — don't over-react
  • Holiday/event spikes (Independence Day): historical model under-predicts — manual override flag
  • Rural zones outside Kampala: limited data → forecast suppressed, show "Insufficient data"
  • Data freshness: ClickHouse lag < 60 s normally; alert if > 5 min
  • Privacy: aggregated metrics only — no individual rider/driver data exposed in analytics surface
  • Cost: ClickHouse storage ~3 yrs hot, then archived to S3 cold storage (cheap)
📜 Audit Log
Immutable record of every admin action · 5-year retention · regulator-ready
Actions today
312
Across 14 admins
Pricing changes
3
SuperAdmin only
Account freezes
5
Fraud + KYC
Sensitive PII reads
12
Reason required
Recent admin actions
TimeAdminActionTargetBefore → AfterReason
10:42Stella AtimPricing changeKampala · BodaSurge cap 1.6× → 1.8×Rush-hour expansion
10:18Patrick B.Account freezeU-1044 · Driver SsaliActive → FrozenFraud FA-2241
09:55Amina N.KYC approveU-1043 · Kato MosesPending → Active
09:42Patrick B.PII revealU-1023Read full phoneDispute DSP-0041
09:30Sarah N.Refund issuedDSP-0039UGX 0 → UGX 12,400MoMo charged, no ride
09:14Ivan B.Dispute resolveDSP-0040Open → ResolvedDriver no-show confirmed
08:48Stella AtimRole changeU-A007 · New analystNone → fraud_analystNew hire
08:30SystemAuto-suspendU-1051 · DriverActive → SuspendedInsurance expired
Component
📜 Audit Log Screen 9
  • Single chronological feed of every admin action — humans + system actors
  • Severity badges highlight high-risk actions (pricing, freeze, role change, PII reveal)
  • "Before → After" diff column is critical for investigation and rollback
  • Reason field is required for sensitive actions — UI enforces non-empty before submit
  • Filter chips: by action type, admin user, target user, date range
  • Export: tamper-evident PDF (signed) for regulatory submissions
Architectural Decisions
🏗️ Architecture — Audit
  • admin_audit_log table: append-only PostgreSQL table — DB role lacks UPDATE/DELETE permission
  • Hash-chained: each row stores hash of (prev_row + this_row) — tamper-evident
  • Daily Merkle root anchored to S3 + emailed to compliance — cryptographic proof of integrity
  • Sensitive PII read events captured automatically by API Gateway middleware
  • System actor (e.g. cron jobs) logged distinctly from human actions for transparency
  • 5-year retention required by Uganda regulations — auto-archive to S3 cold storage after 1 yr
API & Data Flow
🔌 API — Audit
  • GET /admin/audit?actor=&target=&type=&from=&to=&page= → paginated audit trail
  • GET /admin/audit/{event_id} → full event with before/after JSON diff
  • GET /admin/audit/integrity?date= → Merkle root + verification status
  • GET /admin/audit/export?from=&to=&format=pdf|csv → signed S3 URL
  • Server-side: Kafka consumer "admin.action" → append-only insert (no API for writes)
Edge Cases & Implementation Notes
⚙️ Implementation Notes
  • Audit log itself cannot be edited — even by SuperAdmin (compliance posture)
  • Bulk actions logged as parent + N child events — easy to count, easy to investigate
  • Failed actions also logged (e.g. attempted PII reveal denied by RBAC) — security signal
  • Search performance: indexed by (actor, target, time) — Elasticsearch mirror for full-text
  • Regulator export typically requested annually — 5-yr archive must be re-fetchable in < 24 h
  • Anomaly detection: unusual action patterns (e.g. mass freeze at 03:00) auto-alert SecOps
⚙️ Platform Settings
Cities, feature flags, regulatory caps, and integrations · SuperAdmin only
Cities & launch status
CityStatusDriversTrips/dayCurrency
🇺🇬 KampalaLive4121,842UGX
🇺🇬 JinjaPilot34108UGX
🇺🇬 MbararaSoft launch1842UGX
🇺🇬 GuluPlanned · Q3UGX
Feature flags
USSD fallback
Allow ride booking via *123#
ON
Share trip link
Public live-trip share via web link
ON
Delivery service
Parcel delivery (boda)
ON
Share rides (carpool)
Multi-passenger pooling
PILOT · Kampala
In-app voice (Luganda)
Audio walkthrough
OFF
Regulatory & policy caps
Surge cap (regulatory max)1.8×
Driver minimum (UGX/km)UGX 800
Daily withdrawal cap (AML)UGX 1,000,000
KYC re-verify (months)12
Trip data retention (years)5
Integrations
📱 MTN MoMo Collection APIHealthy · 99.94%
📱 Airtel Money APIDegraded · API delays
📩 Africa's Talking SMSHealthy
🛂 Smile ID (KYC biometric)Healthy
🗺️ Google MapsHealthy
⛽ NITA-U Fuel IndexHealthy · Updated weekly
Component
⚙️ Settings Screen 10
  • City roster: launch status (Live / Pilot / Soft / Planned), key metrics per city
  • Feature flags: granular ON / OFF + per-city scope (e.g. Pilot in Kampala only)
  • Regulatory caps: surge max, driver minimum/km, AML withdrawal cap, retention windows
  • Integrations health board: real-time status of MoMo, Airtel, SMS, KYC biometric, Maps, fuel index
  • SuperAdmin-only access — surface gated by RBAC scope
  • All changes flow through Audit Log with before/after diff
Architectural Decisions
🏗️ Architecture — Settings
  • Settings stored in PostgreSQL platform_config (one row per key, version-tracked)
  • Feature flags via LaunchDarkly-style internal service — instant rollout/rollback without deploy
  • Per-city scoping: each setting can have global default + per-city override
  • Integration health: ping checks every 60 s; status surfaced live via WebSocket
  • Regulatory caps require 2-person rule — second SuperAdmin must co-sign change
  • Config change → Kafka platform.config.updated → all microservice replicas reload within 5 s
API & Data Flow
🔌 API — Settings
  • GET /admin/settings/cities → city roster with status + metrics
  • GET /admin/settings/feature-flags → all flags with current scope
  • PUT /admin/settings/feature-flags/{key} { value, scope: "global"|"city:Kampala" }
  • GET /admin/settings/regulatory → surge cap, AML caps, retention windows
  • PUT /admin/settings/regulatory/{key} { value, cosigner_id, reason } — 2-person required
  • GET /admin/integrations/health → real-time status of all third-party APIs
Edge Cases & Implementation Notes
⚙️ Implementation Notes
  • Surge cap change beyond 2.0× requires legal sign-off — UI hard-blocks above threshold
  • City launch checklist enforced (pricing config, drivers ≥ 30, ops staff assigned, regulator notified)
  • Feature flag rollback: one-click revert; new state propagates within 5 s via Kafka
  • Integration outage: alert SecOps + auto-degrade UX (e.g. hide Airtel option if API down > 5 min)
  • Config drift detection: nightly job compares live config vs git-versioned source-of-truth
  • Multi-region future: settings need to scope by country, not just city — schema designed for it