Screen 1 of 17 — Sign-in
Twende Ops
Sign in to the operations console
Work email
Password
Trust this device for 30 days
or
Forgot your password?
Restricted to authorized Twende staff. All actions are audited.
Need access? Contact it@twende.ug
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
Twende Ops
Two-factor verification
Open your authenticator app (Google Authenticator, Authy, 1Password) and enter the 6-digit code.
7
3
2
Code refreshes in 28s
Lost your authenticator? Use a backup code
⚠️ 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)
Twende Ops
Continue with Google Workspace
👤
stella.atim@twende.ug
Twende Operations · SuperAdmin
✓ Email verified by Google Workspace · domain twende.ug
Use a different account
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
Twende Ops
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.
← Back to sign-in
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
| ID | Type | User | Score | Severity | Actions |
|---|---|---|---|---|---|
| FA-2241 | GPS Spoof | UBD-123X | 0.94 | Critical | |
| FA-2240 | Cancel Abuse | Ssali J. | 0.81 | High | |
| FA-2239 | Promo Abuse | +256-709… | 0.72 | High | |
| FA-2238 | Device Reuse | Nakato A. | 0.65 | Medium | |
| FA-2237 | Route Deviation | UBD-456Y | 0.58 | Medium |
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
💱 FX · USD/UGX
UGX 3,810
🚦 Traffic · Google DM
+18% above free-flow
📈 Demand · last 5 min
142 quotes
🌧 Weather · OpenWeatherMap
Light rain
📅 Time-of-day · Calendar
Rush hour 17:00–19:00
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
| Zone | Demand | Traffic | Surge | Avg fare | Quotes | Decision |
|---|---|---|---|---|---|---|
| Wandegeya | 0.78 | +22% | 1.45× | UGX 9,840 | 34 | Peak surge |
| City Centre | 0.62 | +18% | 1.30× | UGX 8,420 | 42 | Surge |
| Kololo | 0.34 | +8% | 1.05× | UGX 7,100 | 18 | Normal |
| Ntinda | 0.21 | +4% | 1.00× | UGX 6,800 | 12 | Normal |
| Bugolobi | 0.45 | +12% | 1.18× | UGX 7,650 | 22 | Mild surge |
| Katwe | 0.28 | +15% | 1.08× | UGX 6,920 | 14 | Normal |
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
💱
FX · USD/UGX (Bank of Uganda mid)
Healthy
3,810
UGX per USD
↑ +1.2% wk
⛽
Fuel · Diesel (for car drivers)
Healthy
UGX 5,180
/litre
↑ +3.8% wk
💱
FX · KES/UGX (East Africa expansion)
Inactive
28.4
UGX per KES
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
Live Fare SimulatorPreview
Showing estimated fares with current settings for sample trips:
| Route | Dist | Base | Surge | Total | Status |
|---|---|---|---|---|---|
| Acacia → Nakasero | 4.2 km | 7,500 | 1.3× | 9,750 | Surge |
| Wandegeya → Uni | 2.1 km | 4,200 | 1.0× | 4,200 | Normal |
| Ntinda → City | 7.8 km | 11,400 | 1.3× | 14,820 | Surge |
| Muyenga → Bugolobi | 3.5 km | 6,000 | 1.0× | 6,000 | Normal |
📌 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.
[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
| Driver | Phone | Vehicle | Submitted | SLA | Action |
|---|---|---|---|---|---|
| Kato Moses | +256 772 ••• 891 | 🏍️ Boda · UBD 123X | 2 h ago | On track | |
| Sarah Namutebi | +256 700 ••• 234 | 🚗 Car · UAB 456Z | 6 h ago | On track | |
| James Ssali | +256 752 ••• 105 | 🏍️ Boda · UBC 789Y | 1 d ago | 14 h left | |
| Patrick Odong | +256 754 ••• 567 | 🚗 Car · UAA 234X | 2 d ago | ⏰ Overdue | |
| Amina Nakato | +256 758 ••• 902 | 🏍️ Boda · UBE 567Q | 3 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
| Driver | Issue | Detected by | Registered → Actual | Status | Action |
|---|---|---|---|---|---|
| Ssali J. | Plate mismatch | Pre-shift OCR | UBD 123X → UBC 456Y | Auto-suspended | |
| Odong P. | Passenger report | Pickup verify | UAB 789Z → unknown | Auto-suspended | |
| Nakato A. | Color mismatch | Passenger report | Red Bajaj → Blue Bajaj | Under review | |
| Mukasa K. | Change request | Driver self-report | Bajaj → Honda (accident) | Docs pending | |
| Tumusiime J. | Insurance lapsed | Auto-detect | Insurance expired 2 May | Suspended |
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
| ID | Name | Phone | Role | Status | Trips | Joined | Actions |
|---|---|---|---|---|---|---|---|
| U-1042 | Sarah Namukasa | +256 712 ••• 678 | Passenger | Active | 47 | Jan 2024 | |
| U-1043 | Kato Moses | +256 772 ••• 891 | Driver | Active | 218 | Mar 2024 | |
| U-1044 | James Ssali | +256 752 ••• 105 | Driver | Suspended | 92 | Feb 2024 | |
| U-1045 | Amina Nakato | +256 758 ••• 902 | Driver | Pending KYC | 0 | 3 d ago | |
| U-A001 | Patrick Byarugaba | (internal) | Admin · Ops | Active | — | Nov 2023 | |
| U-A002 | Stella Atim | (internal) | Admin · SuperAdmin | Active | — | Oct 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
| Date | Operator | Operator total | Ledger total | Diff | Status |
|---|---|---|---|---|---|
| 2 May | MTN MoMo | UGX 9,840,200 | UGX 9,840,200 | UGX 0 | Matched |
| 2 May | Airtel Money | UGX 4,950,400 | UGX 4,932,000 | +UGX 18,400 | Break |
| 1 May | MTN MoMo | UGX 8,200,800 | UGX 8,200,800 | UGX 0 | Matched |
| 1 May | Airtel Money | UGX 3,840,000 | UGX 3,840,000 | UGX 0 | Matched |
| 30 Apr | Cash trips | — | UGX 612,000 | — | Self-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
Actual trips
30-min forecast
Demand forecast — next 30 min
| Zone | Now | +30 min | Δ |
|---|---|---|---|
| Wandegeya | 14 | 22 | +57% |
| City Centre | 18 | 20 | +11% |
| Kololo | 9 | 10 | +11% |
| Ntinda | 12 | 8 | −33% |
| Bugolobi | 6 | 7 | +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
| Time | Admin | Action | Target | Before → After | Reason |
|---|---|---|---|---|---|
| 10:42 | Stella Atim | Pricing change | Kampala · Boda | Surge cap 1.6× → 1.8× | Rush-hour expansion |
| 10:18 | Patrick B. | Account freeze | U-1044 · Driver Ssali | Active → Frozen | Fraud FA-2241 |
| 09:55 | Amina N. | KYC approve | U-1043 · Kato Moses | Pending → Active | — |
| 09:42 | Patrick B. | PII reveal | U-1023 | Read full phone | Dispute DSP-0041 |
| 09:30 | Sarah N. | Refund issued | DSP-0039 | UGX 0 → UGX 12,400 | MoMo charged, no ride |
| 09:14 | Ivan B. | Dispute resolve | DSP-0040 | Open → Resolved | Driver no-show confirmed |
| 08:48 | Stella Atim | Role change | U-A007 · New analyst | None → fraud_analyst | New hire |
| 08:30 | System | Auto-suspend | U-1051 · Driver | Active → Suspended | Insurance 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
| City | Status | Drivers | Trips/day | Currency |
|---|---|---|---|---|
| 🇺🇬 Kampala | Live | 412 | 1,842 | UGX |
| 🇺🇬 Jinja | Pilot | 34 | 108 | UGX |
| 🇺🇬 Mbarara | Soft launch | 18 | 42 | UGX |
| 🇺🇬 Gulu | Planned · Q3 | — | — | UGX |
Feature flags
USSD fallback
Allow ride booking via *123#
Share trip link
Public live-trip share via web link
Delivery service
Parcel delivery (boda)
Share rides (carpool)
Multi-passenger pooling
In-app voice (Luganda)
Audio walkthrough
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