Date: October 8, 2025
Current Status: MVP Completed - Pre-Phase 1
Target Vision: On-Demand Moving Platform (Uber/Bolt Model for Moving Services)
Status Note: The current application is a functional MVP with basic quote generation and user authentication. Phase 1 (Marketplace Foundation) detailed below represents planned work for Months 1-3 to transform the MVP into a full two-sided marketplace.
Your app is a solid MVP with good fundamentals:
- ✅ Strong Foundation: Supabase + React + TypeScript + Capacitor
- ✅ Core Features: Quote generation, AI estimation, basic user auth
- ✅ Legal Compliance: Comprehensive privacy policy (Kenya DPA 2019)
- ✅ Market Ready: Google Play submission materials complete
Missing Critical Components (Blocking Scale):
- Real-time mover tracking & availability (GPS, geolocation)
- Dynamic pricing engine (surge pricing, demand-based)
- Two-sided marketplace (mover onboarding, verification, ratings)
- In-app payments (M-Pesa, cards, escrow)
- Real-time booking & dispatch system
- Live chat & notifications
- Route optimization & fleet management
Estimated Timeline to Uber-Parity:
- Phase 1 (Next 3 months): Core marketplace features
- Phase 2 (Months 4-6): Real-time tracking & payments
- Phase 3 (Months 7-12): AI optimization & scale infrastructure
-
Modern Tech Stack
- React 18 + TypeScript (type safety)
- Supabase (scalable backend, real-time capabilities)
- Capacitor (cross-platform mobile)
- TanStack Query (excellent data management)
-
Good Engineering Practices
- Component-based architecture
- Error boundaries
- Performance monitoring stub
- Clean separation of concerns
-
Business Fundamentals
- User authentication (Supabase Auth)
- Quote request workflow
- Basic AI estimation logic
- Google Maps integration (Places API)
// Current: All logic in QuoteForm.tsx (400+ lines)
// Problem: Hard to maintain, test, scaleIssue: Quote generation, pricing, validation all in one component
Impact: Cannot easily add mover-side features, testing nightmare
Fix: Implement Domain-Driven Design with separate services
// Current: generateAIQuoteEstimation() - hardcoded multipliers
const base = 12000
const sizeMultipliers = { 'Bedsitter': 0.7, '1BR': 1, ... }Issue: No real AI, no market dynamics, no surge pricing
Impact: Cannot compete with demand, loss of revenue optimization
Fix: Implement ML-based dynamic pricing engine
Missing:
- Live mover location tracking
- Real-time availability updates
- Push notifications
- WebSocket connections
Impact: Cannot do on-demand bookings like Uber
Fix: Implement Supabase Realtime + Geolocation services
// Current: MoverDashboard is mostly placeholderMissing:
- Mover onboarding & KYC
- Availability calendar
- Earnings dashboard
- Route optimization
- Job acceptance/rejection workflow
Impact: No mover retention, no supply-side growth
Fix: Build complete mover app (separate or in-app)
Missing:
- M-Pesa integration (critical for Kenya)
- Payment escrow
- Automatic payouts
- Commission tracking
- Refund handling
Impact: Manual payments → friction → churn
Fix: Integrate M-Pesa Daraja API + Stripe
Current Schema Issues:
- No mover profiles table
- No bookings/trips table
- No ratings/reviews table
- No real-time location tracking
- No earnings/transactions table
Impact: Cannot build two-sided marketplace
Fix: Implement comprehensive database schema
Goal: Transform from quote generator to two-sided marketplace
-- New Tables Needed
CREATE TABLE movers (
id UUID PRIMARY KEY,
user_id UUID REFERENCES auth.users,
business_name TEXT NOT NULL,
vehicle_types TEXT[], -- ['Pickup', 'Box Truck', 'Container']
service_areas GEOGRAPHY[], -- PostGIS for geofencing
availability_status TEXT, -- 'online', 'offline', 'busy'
rating DECIMAL(3,2),
total_moves INTEGER DEFAULT 0,
verification_status TEXT, -- 'pending', 'verified', 'suspended'
documents JSONB, -- KRA PIN, licenses, insurance
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE bookings (
id UUID PRIMARY KEY,
customer_id UUID REFERENCES auth.users,
mover_id UUID REFERENCES movers,
quote_id UUID REFERENCES quotes,
status TEXT, -- 'pending', 'accepted', 'in_progress', 'completed', 'cancelled'
pickup_location GEOGRAPHY(POINT),
dropoff_location GEOGRAPHY(POINT),
scheduled_time TIMESTAMPTZ,
estimated_price DECIMAL(10,2),
final_price DECIMAL(10,2),
distance_meters INTEGER,
payment_status TEXT,
tracking_data JSONB, -- Real-time location updates
created_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE TABLE payments (
id UUID PRIMARY KEY,
booking_id UUID REFERENCES bookings,
amount DECIMAL(10,2),
payment_method TEXT, -- 'mpesa', 'card', 'cash'
transaction_id TEXT,
status TEXT, -- 'pending', 'completed', 'failed', 'refunded'
commission_amount DECIMAL(10,2),
mover_payout_amount DECIMAL(10,2),
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE ratings (
id UUID PRIMARY KEY,
booking_id UUID REFERENCES bookings,
rater_id UUID REFERENCES auth.users,
rated_id UUID REFERENCES auth.users,
rating INTEGER CHECK (rating BETWEEN 1 AND 5),
comment TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE mover_locations (
id UUID PRIMARY KEY,
mover_id UUID REFERENCES movers,
location GEOGRAPHY(POINT),
heading DECIMAL(5,2), -- Direction in degrees
speed DECIMAL(5,2), -- km/h
accuracy DECIMAL(8,2), -- meters
timestamp TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes for performance
-- Geospatial indexes (GIST for geography types)
CREATE INDEX idx_movers_service_areas ON movers USING GIST(service_areas);
CREATE INDEX idx_bookings_pickup_location ON bookings USING GIST(pickup_location);
CREATE INDEX idx_bookings_dropoff_location ON bookings USING GIST(dropoff_location);
CREATE INDEX idx_mover_locations_location ON mover_locations USING GIST(location);
-- Foreign key indexes (critical for join performance)
CREATE INDEX idx_bookings_customer_id ON bookings(customer_id);
CREATE INDEX idx_bookings_mover_id ON bookings(mover_id);
CREATE INDEX idx_bookings_quote_id ON bookings(quote_id);
CREATE INDEX idx_payments_booking_id ON payments(booking_id);
CREATE INDEX idx_ratings_booking_id ON ratings(booking_id);
CREATE INDEX idx_ratings_rater_id ON ratings(rater_id);
CREATE INDEX idx_ratings_rated_id ON ratings(rated_id);
CREATE INDEX idx_mover_locations_mover_id ON mover_locations(mover_id);
-- Composite indexes for common query patterns
CREATE INDEX idx_bookings_mover_status ON bookings(mover_id, status);
CREATE INDEX idx_bookings_customer_status ON bookings(customer_id, status);
CREATE INDEX idx_bookings_status_scheduled ON bookings(status, scheduled_time);
CREATE INDEX idx_bookings_status_created ON bookings(status, created_at DESC);
-- Filtered index for finding available verified movers (sorted by rating)
CREATE INDEX idx_movers_available_verified ON movers(availability_status, rating DESC)
WHERE verification_status = 'verified';
-- Composite index for mover earnings/dashboard queries
CREATE INDEX idx_bookings_mover_completed ON bookings(mover_id, completed_at DESC)
WHERE status = 'completed';
-- Payment processing and reconciliation indexes
CREATE INDEX idx_payments_status_created ON payments(status, created_at DESC);
CREATE INDEX idx_payments_mover_status ON payments(mover_id, status);
CREATE INDEX idx_payments_transaction_id ON payments(transaction_id); -- For M-Pesa lookups
-- Time-series indexes for analytics and tracking
CREATE INDEX idx_mover_locations_mover_time ON mover_locations(mover_id, timestamp DESC);
CREATE INDEX idx_bookings_completed_at ON bookings(completed_at DESC)
WHERE completed_at IS NOT NULL;
-- Rating aggregation indexes
CREATE INDEX idx_ratings_rated_created ON ratings(rated_id, created_at DESC);
CREATE INDEX idx_ratings_booking_created ON ratings(booking_id, created_at DESC);Features to Build:
// src/features/mover-onboarding/
- MoverRegistrationWizard.tsx
- Step 1: Business Information
- Step 2: Vehicle & Equipment Details
- Step 3: Service Areas (Map Selection)
- Step 4: Document Upload (KRA, Insurance, License)
- Step 5: Bank/M-Pesa Details
- Step 6: Background Check Consent
// Backend: Verification Pipeline
- supabase/functions/verify-mover/
- KRA PIN verification (iTax API)
- Background check integration (CRB)
- Document OCR & validation
- Manual review queue for admins// New Components Needed
src/features/booking/
- BookingRequest.tsx // Customer initiates
- MoverMatchingEngine.tsx // Find nearby available movers
- BookingOffer.tsx // Send to movers
- MoverAcceptance.tsx // Mover accepts/rejects
- BookingTracking.tsx // Live tracking during move
- CompletionFlow.tsx // Payment + Rating
// Real-time Events (Supabase Realtime)
- booking:created → notify nearby movers
- booking:accepted → lock mover, notify customer
- booking:started → begin GPS tracking
- booking:completed → trigger payment flowCritical Requirements for Reliable Real-Time Events:
-- Event log table for idempotency and replay
CREATE TABLE event_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
idempotency_key TEXT UNIQUE NOT NULL, -- Unique event identifier
event_type TEXT NOT NULL, -- 'booking:created', 'booking:accepted', etc.
entity_id UUID NOT NULL, -- booking_id, mover_id, etc.
entity_type TEXT NOT NULL, -- 'booking', 'mover', 'payment'
sequence_number BIGINT, -- For ordering within entity
payload JSONB NOT NULL,
source TEXT, -- 'api', 'webhook', 'system', 'realtime'
-- Processing tracking
processed_at TIMESTAMPTZ,
processing_attempts INTEGER DEFAULT 0,
last_processing_error TEXT,
-- Metadata
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES auth.users,
-- Constraints
CONSTRAINT valid_sequence CHECK (sequence_number >= 0)
);
-- Indexes for event processing
CREATE INDEX idx_event_log_idempotency ON event_log(idempotency_key);
CREATE INDEX idx_event_log_entity ON event_log(entity_type, entity_id, sequence_number);
CREATE INDEX idx_event_log_unprocessed ON event_log(created_at)
WHERE processed_at IS NULL;
CREATE INDEX idx_event_log_type_created ON event_log(event_type, created_at DESC);// src/services/eventProcessor.ts
interface EventPayload {
idempotency_key: string; // UUID or booking_id + event_type + timestamp
event_type: string;
entity_id: string;
entity_type: string;
sequence_number?: number;
data: Record<string, any>;
timestamp: string;
}
async function processEvent(event: EventPayload): Promise<void> {
// Step 1: Check idempotency - already processed?
const { data: existingEvent } = await supabase
.from('event_log')
.select('processed_at')
.eq('idempotency_key', event.idempotency_key)
.single();
if (existingEvent?.processed_at) {
console.log(`Event ${event.idempotency_key} already processed, skipping`);
return; // Idempotent: already processed
}
// Step 2: Persist event to log (atomic operation)
const { error: logError } = await supabase
.from('event_log')
.insert({
idempotency_key: event.idempotency_key,
event_type: event.event_type,
entity_id: event.entity_id,
entity_type: event.entity_type,
sequence_number: event.sequence_number,
payload: event.data,
source: 'realtime'
});
if (logError?.code === '23505') {
// Unique constraint violation: race condition, already logged
console.log('Event already logged by concurrent process');
return;
}
// Step 3: Check for sequence gaps (ordered delivery)
if (event.sequence_number !== undefined) {
const isSequenceValid = await validateEventSequence(
event.entity_type,
event.entity_id,
event.sequence_number
);
if (!isSequenceValid) {
console.warn(`Sequence gap detected for ${event.entity_id}, buffering event`);
await bufferOutOfOrderEvent(event);
return; // Wait for missing events
}
}
try {
// Step 4: Process event business logic
await executeEventHandler(event);
// Step 5: Mark as processed
await supabase
.from('event_log')
.update({ processed_at: new Date().toISOString() })
.eq('idempotency_key', event.idempotency_key);
// Step 6: Check if buffered events can now be processed
await processBufferedEvents(event.entity_type, event.entity_id);
} catch (error) {
// Step 7: Log error and retry logic handled separately
await handleEventProcessingError(event, error);
throw error; // Re-throw for retry mechanism
}
}// Sequence validation and buffering
async function validateEventSequence(
entityType: string,
entityId: string,
sequenceNumber: number
): Promise<boolean> {
// Get last processed sequence for this entity
const { data: lastEvent } = await supabase
.from('event_log')
.select('sequence_number')
.eq('entity_type', entityType)
.eq('entity_id', entityId)
.not('processed_at', 'is', null)
.order('sequence_number', { ascending: false })
.limit(1)
.single();
const expectedSequence = (lastEvent?.sequence_number ?? -1) + 1;
if (sequenceNumber === expectedSequence) {
return true; // Perfect order
}
if (sequenceNumber < expectedSequence) {
console.log(`Duplicate/old event: ${sequenceNumber} < ${expectedSequence}`);
return false; // Old event, ignore
}
// Gap detected: sequenceNumber > expectedSequence
console.warn(`Gap detected: expected ${expectedSequence}, got ${sequenceNumber}`);
return false; // Buffer and wait for missing events
}
// Buffer for out-of-order events
const eventBuffer = new Map<string, EventPayload[]>();
async function bufferOutOfOrderEvent(event: EventPayload): Promise<void> {
const bufferKey = `${event.entity_type}:${event.entity_id}`;
if (!eventBuffer.has(bufferKey)) {
eventBuffer.set(bufferKey, []);
}
eventBuffer.get(bufferKey)!.push(event);
// Set timeout to reconcile gaps after 30 seconds
setTimeout(() => reconcileEventGaps(event.entity_type, event.entity_id), 30000);
}
async function processBufferedEvents(entityType: string, entityId: string): Promise<void> {
const bufferKey = `${entityType}:${entityId}`;
const buffered = eventBuffer.get(bufferKey) || [];
if (buffered.length === 0) return;
// Sort by sequence number
buffered.sort((a, b) => (a.sequence_number ?? 0) - (b.sequence_number ?? 0));
// Process in order
for (const event of buffered) {
const isValid = await validateEventSequence(
event.entity_type,
event.entity_id,
event.sequence_number!
);
if (isValid) {
await processEvent(event);
// Remove from buffer
const index = buffered.indexOf(event);
buffered.splice(index, 1);
} else {
break; // Still gaps, stop processing
}
}
// Clean up buffer if empty
if (buffered.length === 0) {
eventBuffer.delete(bufferKey);
}
}-- Dead letter queue for failed events
CREATE TABLE event_dlq (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_log_id UUID REFERENCES event_log(id),
idempotency_key TEXT NOT NULL,
event_type TEXT NOT NULL,
entity_id UUID NOT NULL,
failure_reason TEXT NOT NULL,
retry_count INTEGER DEFAULT 0,
last_retry_at TIMESTAMPTZ,
payload JSONB NOT NULL,
-- Resolution tracking
resolved_at TIMESTAMPTZ,
resolved_by UUID REFERENCES auth.users,
resolution_notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_dlq_unresolved ON event_dlq(created_at)
WHERE resolved_at IS NULL;
CREATE INDEX idx_dlq_event_type ON event_dlq(event_type);// Retry logic with exponential backoff
async function handleEventProcessingError(
event: EventPayload,
error: any
): Promise<void> {
// Increment processing attempts
const { data: eventLog } = await supabase
.from('event_log')
.select('processing_attempts')
.eq('idempotency_key', event.idempotency_key)
.single();
const attempts = (eventLog?.processing_attempts ?? 0) + 1;
const maxRetries = 5;
await supabase
.from('event_log')
.update({
processing_attempts: attempts,
last_processing_error: error.message
})
.eq('idempotency_key', event.idempotency_key);
if (attempts >= maxRetries) {
// Move to dead letter queue
await supabase.from('event_dlq').insert({
event_log_id: eventLog.id,
idempotency_key: event.idempotency_key,
event_type: event.event_type,
entity_id: event.entity_id,
failure_reason: error.message,
retry_count: attempts,
payload: event.data
});
// Alert operations team
await sendAlert({
level: 'critical',
message: `Event ${event.event_type} failed after ${attempts} attempts`,
details: { idempotency_key: event.idempotency_key, error: error.message }
});
return; // Don't retry further
}
// Exponential backoff: 2^attempts seconds
const backoffMs = Math.pow(2, attempts) * 1000;
setTimeout(async () => {
console.log(`Retrying event ${event.idempotency_key}, attempt ${attempts + 1}`);
await processEvent(event);
}, backoffMs);
}// Store client cursors for replay
interface ClientCursor {
client_id: string;
entity_type: string;
last_processed_sequence: number;
last_processed_timestamp: string;
updated_at: string;
}
// On client reconnect, request missed events
async function handleReconnection(clientId: string): Promise<void> {
// Fetch last cursor position
const { data: cursor } = await supabase
.from('client_cursors')
.select('*')
.eq('client_id', clientId)
.single();
if (!cursor) {
console.log('No cursor found, starting fresh subscription');
return;
}
// Fetch missed events since last processed
const { data: missedEvents } = await supabase
.from('event_log')
.select('*')
.eq('entity_type', cursor.entity_type)
.gt('created_at', cursor.last_processed_timestamp)
.order('sequence_number', { ascending: true });
console.log(`Replaying ${missedEvents?.length ?? 0} missed events`);
// Replay missed events in order
for (const event of missedEvents || []) {
await processEvent({
idempotency_key: event.idempotency_key,
event_type: event.event_type,
entity_id: event.entity_id,
entity_type: event.entity_type,
sequence_number: event.sequence_number,
data: event.payload,
timestamp: event.created_at
});
}
// Update cursor
await updateClientCursor(clientId, cursor.entity_type, missedEvents);
}
// Update cursor after processing events
async function updateClientCursor(
clientId: string,
entityType: string,
events: any[]
): Promise<void> {
if (!events || events.length === 0) return;
const lastEvent = events[events.length - 1];
await supabase
.from('client_cursors')
.upsert({
client_id: clientId,
entity_type: entityType,
last_processed_sequence: lastEvent.sequence_number,
last_processed_timestamp: lastEvent.created_at,
updated_at: new Date().toISOString()
});
}// Monitor event processing health
interface EventProcessingMetrics {
unprocessed_count: number;
avg_processing_time_ms: number;
error_rate: number;
dlq_count: number;
max_sequence_gap: number;
}
async function monitorEventProcessing(): Promise<EventProcessingMetrics> {
// Count unprocessed events
const { count: unprocessedCount } = await supabase
.from('event_log')
.select('*', { count: 'exact', head: true })
.is('processed_at', null);
// Count DLQ entries
const { count: dlqCount } = await supabase
.from('event_dlq')
.select('*', { count: 'exact', head: true })
.is('resolved_at', null);
// Alert if thresholds exceeded
if (unprocessedCount! > 100) {
await sendAlert({
level: 'warning',
message: `${unprocessedCount} unprocessed events in queue`,
});
}
if (dlqCount! > 10) {
await sendAlert({
level: 'critical',
message: `${dlqCount} events in dead letter queue requiring attention`,
});
}
return {
unprocessed_count: unprocessedCount!,
avg_processing_time_ms: 0, // TODO: Calculate from logs
error_rate: 0, // TODO: Calculate from attempts
dlq_count: dlqCount!,
max_sequence_gap: 0 // TODO: Calculate from sequence checks
};
}
// Run monitoring every minute
setInterval(monitorEventProcessing, 60000);Production Checklist:
- ✅ Idempotency keys on all events
- ✅ Event log table with unique constraints
- ✅ Processed_at tracking to avoid duplicates
- ✅ Sequence numbers for ordered delivery
- ✅ Buffer for out-of-order events
- ✅ Gap detection and reconciliation
- ✅ Retry logic with exponential backoff (max 5 attempts)
- ✅ Dead letter queue for failed events
- ✅ Alerting for DLQ and processing delays
- ✅ Client cursors for reconnection replay
- ✅ Monitoring dashboard for event health
---
### **PHASE 2: REAL-TIME OPERATIONS (Months 4-6)**
#### 2.1 GPS Tracking & Geolocation
```typescript
// src/features/tracking/
- useGPSTracking.ts
- Continuous location updates (every 30 seconds)
- Battery-efficient background tracking
- Offline queueing (sync when online)
// Backend: Location Processing
- supabase/functions/process-location/
- Store in mover_locations table
- Broadcast to customer's real-time channel
- Trigger proximity alerts (e.g., "Mover 5 min away")
// Map Integration
- Replace Google Maps with Mapbox (better pricing for real-time)
- Live polyline tracking
- ETA calculations with traffic data
// src/features/pricing/
- DynamicPricingService.ts
- Base Price Calculation (distance, property size)
- Demand Multiplier (time of day, day of week)
- Surge Pricing (high demand areas)
- Seasonal Adjustments (holiday seasons)
- Competitor Price Monitoring (scraping)
// ML Model (Python microservice or Supabase Edge Function with Deno)
supabase/functions/pricing-ml/
- Train on historical bookings data
- Features: time, location, distance, weather, inventory size
- Predict optimal price to maximize booking rate + revenue
- A/B test pricing strategies
// Example Logic
function calculateDynamicPrice(booking: BookingRequest): number {
const basePrice = calculateBasePrice(booking)
const demandMultiplier = getDemandMultiplier(booking.pickup_area, booking.time)
const surgeMultiplier = getSurgeMultiplier(booking.pickup_area)
return basePrice * demandMultiplier * surgeMultiplier
}// M-Pesa Integration (Daraja API)
src/services/mpesa/
- MpesaService.ts
- STK Push (customer payment initiation)
- Payment verification
- Webhook handling (async payment confirmation)
- Refund processing
// Payment Flow
1. Customer books → generate payment request
2. STK Push to customer's phone
3. Customer enters M-Pesa PIN
4. Payment held in escrow (your account)
5. Move completes → release to mover (minus commission)
6. Daily/weekly batch payouts to movers
// Fallback: Card Payments (Stripe)
- For corporate clients
- For users without M-Pesa
// Commission Structure
const COMMISSION_RATE = 0.15 // 15% platform fee
const INSURANCE_FEE = 0.02 // 2% for damage insurance fundRecommended Escrow Holding Period: 48 Hours
// Escrow State Machine
enum EscrowStatus {
HELD = 'held_escrow', // Payment captured, held for 48h
RELEASED = 'released', // Released to mover after successful completion
REFUNDED = 'refunded', // Refunded to customer (cancellation/dispute)
DISPUTED = 'disputed_held' // Held during dispute resolution
}
// Escrow Release Rules
const ESCROW_POLICIES = {
// Standard holding period after completion
STANDARD_HOLD_HOURS: 48,
// Auto-release if no dispute filed within holding period
AUTO_RELEASE_ENABLED: true,
// Early release for highly-rated movers (>4.8 rating, >100 moves)
EARLY_RELEASE_HOURS: 24,
EARLY_RELEASE_CRITERIA: {
min_rating: 4.8,
min_completed_moves: 100
},
// Extended hold for first-time movers
EXTENDED_HOLD_HOURS: 72,
EXTENDED_HOLD_CRITERIA: {
completed_moves: 0
}
};
// Implementation
async function determineEscrowHoldingPeriod(
moverId: string,
bookingId: string
): Promise<number> {
const mover = await getMoverProfile(moverId);
// Early release for trusted movers
if (mover.rating >= 4.8 && mover.total_completed_moves >= 100) {
return ESCROW_POLICIES.EARLY_RELEASE_HOURS;
}
// Extended hold for new movers
if (mover.total_completed_moves === 0) {
return ESCROW_POLICIES.EXTENDED_HOLD_HOURS;
}
// Standard 48-hour hold
return ESCROW_POLICIES.STANDARD_HOLD_HOURS;
}
async function scheduleEscrowRelease(paymentId: string): Promise<void> {
const payment = await getPayment(paymentId);
const holdHours = await determineEscrowHoldingPeriod(
payment.mover_id,
payment.booking_id
);
const releaseAt = new Date(payment.completed_at);
releaseAt.setHours(releaseAt.getHours() + holdHours);
// Update payment with release timestamp
await supabase
.from('payments')
.update({
escrow_status: 'held_escrow',
held_until: releaseAt.toISOString()
})
.eq('id', paymentId);
// Schedule automatic release (use pg_cron or background job)
await scheduleJob('release-escrow', releaseAt, { paymentId });
}Dispute Status Lifecycle:
none → pending → under_review → evidence_collection → resolved
-- Add dispute columns to bookings table
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS dispute_status TEXT
CHECK (dispute_status IN ('none', 'pending', 'under_review', 'evidence_collection', 'resolved'))
DEFAULT 'none';
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS dispute_reason TEXT;
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS dispute_filed_by UUID REFERENCES auth.users;
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS dispute_filed_at TIMESTAMPTZ;
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS dispute_resolved_at TIMESTAMPTZ;
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS dispute_resolution TEXT; -- 'refund_customer', 'payout_mover', 'partial_refund'
-- Dispute evidence table
CREATE TABLE dispute_evidence (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
booking_id UUID REFERENCES bookings(id) ON DELETE CASCADE,
submitted_by UUID REFERENCES auth.users NOT NULL,
evidence_type TEXT NOT NULL, -- 'photo', 'video', 'document', 'statement'
file_url TEXT,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Add index for dispute queries
CREATE INDEX idx_bookings_dispute_status ON bookings(dispute_status)
WHERE dispute_status != 'none';
CREATE INDEX idx_dispute_evidence_booking ON dispute_evidence(booking_id);// 1. Customer/Mover Files Dispute
async function fileDispute(
bookingId: string,
filedBy: string, // user_id
reason: string,
evidence: DisputeEvidence[]
): Promise<void> {
const booking = await getBooking(bookingId);
// Validation: Can only file dispute within holding period
if (booking.completed_at) {
const completedAt = new Date(booking.completed_at);
const now = new Date();
const hoursSinceCompletion = (now.getTime() - completedAt.getTime()) / (1000 * 60 * 60);
const payment = await getPaymentByBooking(bookingId);
const holdHours = await determineEscrowHoldingPeriod(booking.mover_id, bookingId);
if (hoursSinceCompletion > holdHours) {
throw new Error(
`Dispute window closed. Disputes must be filed within ${holdHours} hours of completion.`
);
}
}
// Update booking dispute status
await supabase
.from('bookings')
.update({
dispute_status: 'pending',
dispute_reason: reason,
dispute_filed_by: filedBy,
dispute_filed_at: new Date().toISOString()
})
.eq('id', bookingId);
// Update payment to freeze escrow
await supabase
.from('payments')
.update({
escrow_status: 'disputed_held'
})
.eq('booking_id', bookingId);
// Store evidence
for (const ev of evidence) {
await supabase
.from('dispute_evidence')
.insert({
booking_id: bookingId,
submitted_by: filedBy,
evidence_type: ev.type,
file_url: ev.fileUrl,
description: ev.description
});
}
// Notify admin team for review
await notifyAdminTeam({
type: 'new_dispute',
bookingId,
reason,
priority: 'high'
});
// Notify other party
const otherPartyId = filedBy === booking.customer_id ? booking.mover_id : booking.customer_id;
await sendNotification(otherPartyId, {
title: 'Dispute Filed',
body: 'A dispute has been filed for your booking. You have 24 hours to submit evidence.',
action: 'view_dispute'
});
}
// 2. Evidence Collection Period (24 hours for each party)
const EVIDENCE_COLLECTION_PERIOD_HOURS = 24;
async function requestEvidenceFromParty(
bookingId: string,
partyId: string
): Promise<void> {
await supabase
.from('bookings')
.update({ dispute_status: 'evidence_collection' })
.eq('id', bookingId);
await sendNotification(partyId, {
title: 'Submit Dispute Evidence',
body: 'You have 24 hours to submit evidence for the dispute.',
action: 'submit_evidence'
});
// Set deadline
const deadline = new Date();
deadline.setHours(deadline.getHours() + EVIDENCE_COLLECTION_PERIOD_HOURS);
await scheduleJob('escalate-to-review', deadline, { bookingId });
}
// 3. Admin Review & Resolution
async function resolveDispute(
bookingId: string,
resolution: 'refund_customer' | 'payout_mover' | 'partial_refund',
partialRefundPercentage?: number,
notes?: string
): Promise<void> {
const booking = await getBooking(bookingId);
const payment = await getPaymentByBooking(bookingId);
// Update dispute status
await supabase
.from('bookings')
.update({
dispute_status: 'resolved',
dispute_resolved_at: new Date().toISOString(),
dispute_resolution: resolution
})
.eq('id', bookingId);
// Execute resolution
switch (resolution) {
case 'refund_customer':
await refundPayment(payment.id, payment.amount, 'dispute_resolved_customer_favor');
await supabase
.from('payments')
.update({ escrow_status: 'refunded' })
.eq('id', payment.id);
break;
case 'payout_mover':
await releaseEscrowToMover(payment.id);
await supabase
.from('payments')
.update({ escrow_status: 'released' })
.eq('id', payment.id);
break;
case 'partial_refund':
if (!partialRefundPercentage) throw new Error('Partial refund percentage required');
const refundAmount = payment.amount * (partialRefundPercentage / 100);
const moverPayoutAmount = payment.amount - refundAmount - payment.commission_amount;
await refundPayment(payment.id, refundAmount, 'dispute_partial_refund');
await payoutToMover(payment.mover_id, moverPayoutAmount);
await supabase
.from('payments')
.update({
escrow_status: 'resolved',
refund_amount: refundAmount,
mover_payout_amount: moverPayoutAmount
})
.eq('id', payment.id);
break;
}
// Notify both parties
await notifyDisputeResolution(booking, resolution, notes);
}
// Dispute Timeouts
const DISPUTE_TIMEOUTS = {
EVIDENCE_SUBMISSION: 24, // hours
ADMIN_REVIEW: 72, // hours
ESCALATION: 168 // hours (7 days) - escalate to senior review
};// Milestone-based escrow for large moves
interface MoveCompletion {
booking_id: string;
milestone: 'loading' | 'in_transit' | 'unloading' | 'completed';
release_percentage: number;
confirmed_by: 'customer' | 'mover' | 'both';
}
const MILESTONE_RELEASE_SCHEDULE = {
loading: 25, // 25% after loading confirmed
in_transit: 0, // 0% during transit (held)
unloading: 50, // 50% after unloading confirmed
completed: 25 // Final 25% after customer confirmation
};
async function processMilestoneRelease(
bookingId: string,
milestone: string
): Promise<void> {
const payment = await getPaymentByBooking(bookingId);
const releasePercentage = MILESTONE_RELEASE_SCHEDULE[milestone];
if (releasePercentage === 0) return;
const releaseAmount =
(payment.amount - payment.commission_amount) * (releasePercentage / 100);
await payoutToMover(payment.mover_id, releaseAmount);
// Track milestone completion
await supabase
.from('booking_milestones')
.insert({
booking_id: bookingId,
milestone,
release_percentage: releasePercentage,
release_amount: releaseAmount,
completed_at: new Date().toISOString()
});
}
// Pro-rata refunds for partial completion
async function calculateProRataRefund(
bookingId: string,
completedPercentage: number
): Promise<number> {
const payment = await getPaymentByBooking(bookingId);
// Customer pays only for completed portion
const chargeAmount = payment.amount * (completedPercentage / 100);
const refundAmount = payment.amount - chargeAmount;
return refundAmount;
}-- Add cancellation tracking columns
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS cancelled_by UUID REFERENCES auth.users;
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS cancellation_fee DECIMAL(10,2) DEFAULT 0;
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS cancellation_reason TEXT;
-- Payment refund tracking
ALTER TABLE payments ADD COLUMN IF NOT EXISTS refund_amount DECIMAL(10,2);
ALTER TABLE payments ADD COLUMN IF NOT EXISTS refund_reason TEXT;
ALTER TABLE payments ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMPTZ;
-- Indexes
CREATE INDEX idx_bookings_cancelled_by ON bookings(cancelled_by);
CREATE INDEX idx_payments_refunded ON payments(refunded_at)
WHERE refunded_at IS NOT NULL;// Cancellation Policy
enum CancellationWindow {
BEFORE_ACCEPTANCE = 'before_acceptance', // Before mover accepts
BEFORE_DISPATCH = 'before_dispatch', // After acceptance, before mover en route
AFTER_DISPATCH = 'after_dispatch', // Mover already en route
NO_SHOW = 'no_show' // Customer not present at pickup
}
const CANCELLATION_POLICIES = {
// Full refund: Before mover accepts or >24h before scheduled time
BEFORE_ACCEPTANCE: {
refund_percentage: 100,
cancellation_fee: 0
},
// Full refund: >24h before scheduled time (even if accepted)
BEFORE_DISPATCH_24H: {
refund_percentage: 100,
cancellation_fee: 0
},
// Partial refund: <24h but before mover dispatched
BEFORE_DISPATCH_LATE: {
refund_percentage: 50,
cancellation_fee_percentage: 50 // Charge 50% of booking amount
},
// Minimal refund: Mover already en route
AFTER_DISPATCH: {
refund_percentage: 25,
cancellation_fee_percentage: 75,
include_transport_compensation: true
},
// No refund: Customer no-show
NO_SHOW: {
refund_percentage: 0,
cancellation_fee_percentage: 100,
mover_compensation: 100 // Mover gets full payment
}
};
async function calculateCancellationRefund(
bookingId: string,
cancelledBy: string
): Promise<CancellationResult> {
const booking = await getBooking(bookingId);
const payment = await getPaymentByBooking(bookingId);
const now = new Date();
const scheduledTime = new Date(booking.scheduled_date);
const hoursUntilMove = (scheduledTime.getTime() - now.getTime()) / (1000 * 60 * 60);
let policy: any;
let window: CancellationWindow;
// Determine cancellation window
if (booking.status === 'pending') {
// Before mover accepted
policy = CANCELLATION_POLICIES.BEFORE_ACCEPTANCE;
window = CancellationWindow.BEFORE_ACCEPTANCE;
} else if (booking.status === 'accepted' && hoursUntilMove > 24) {
// More than 24h before move
policy = CANCELLATION_POLICIES.BEFORE_DISPATCH_24H;
window = CancellationWindow.BEFORE_DISPATCH;
} else if (booking.status === 'accepted' && booking.status !== 'mover_en_route') {
// Less than 24h, mover not dispatched yet
policy = CANCELLATION_POLICIES.BEFORE_DISPATCH_LATE;
window = CancellationWindow.BEFORE_DISPATCH;
} else if (booking.status === 'mover_en_route' || booking.status === 'in_progress') {
// Mover already dispatched or in progress
policy = CANCELLATION_POLICIES.AFTER_DISPATCH;
window = CancellationWindow.AFTER_DISPATCH;
} else {
throw new Error('Cannot cancel booking in current state');
}
// Calculate refund and fee
const refundAmount = payment.amount * (policy.refund_percentage / 100);
const cancellationFee = payment.amount * ((policy.cancellation_fee_percentage || 0) / 100);
return {
window,
refund_amount: refundAmount,
cancellation_fee: cancellationFee,
mover_compensation: policy.mover_compensation
? payment.amount - payment.commission_amount
: cancellationFee * 0.8, // Mover gets 80% of cancellation fee
policy_applied: policy
};
}
async function processCancellation(
bookingId: string,
cancelledBy: string,
reason: string
): Promise<void> {
const booking = await getBooking(bookingId);
const cancellation = await calculateCancellationRefund(bookingId, cancelledBy);
// Update booking
const cancellationStatus = cancelledBy === booking.customer_id
? 'cancelled_customer'
: 'cancelled_mover';
await supabase
.from('bookings')
.update({
status: cancellationStatus,
cancelled_at: new Date().toISOString(),
cancelled_by: cancelledBy,
cancellation_fee: cancellation.cancellation_fee,
cancellation_reason: reason
})
.eq('id', bookingId);
// Process refund if applicable
if (cancellation.refund_amount > 0) {
await refundPayment(
booking.payment_id,
cancellation.refund_amount,
`Cancellation: ${cancellation.window}`
);
}
// Compensate mover if applicable
if (cancellation.mover_compensation > 0 && booking.mover_id) {
await payoutToMover(
booking.mover_id,
cancellation.mover_compensation,
'Cancellation compensation'
);
}
// Update payment record
await supabase
.from('payments')
.update({
refund_amount: cancellation.refund_amount,
refund_reason: `Cancellation in ${cancellation.window} window`,
refunded_at: cancellation.refund_amount > 0 ? new Date().toISOString() : null,
mover_payout_amount: cancellation.mover_compensation
})
.eq('booking_id', bookingId);
// Notify both parties
await notifyCancellation(booking, cancellation, cancelledBy, reason);
}
// Handle no-show scenario
async function handleNoShow(bookingId: string): Promise<void> {
const booking = await getBooking(bookingId);
const payment = await getPaymentByBooking(bookingId);
// Mark as no-show cancellation
await processCancellation(
bookingId,
booking.customer_id,
'Customer no-show at pickup location'
);
// Full payment to mover as compensation
await payoutToMover(
booking.mover_id,
payment.amount - payment.commission_amount,
'No-show compensation'
);
// Potentially penalize customer (reduce trust score, temporary booking restriction)
await applyNoShowPenalty(booking.customer_id);
}-- ============================================================================
-- PAYMENT & DISPUTE ENHANCEMENT MIGRATION
-- ============================================================================
-- 1. Add escrow holding tracking to payments table
ALTER TABLE payments
ADD COLUMN IF NOT EXISTS escrow_status TEXT
CHECK (escrow_status IN ('held_escrow', 'released', 'refunded', 'disputed_held', 'resolved'))
DEFAULT 'held_escrow',
ADD COLUMN IF NOT EXISTS held_until TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS released_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS release_reason TEXT;
-- 2. Add dispute tracking to bookings table
ALTER TABLE bookings
ADD COLUMN IF NOT EXISTS dispute_status TEXT
CHECK (dispute_status IN ('none', 'pending', 'under_review', 'evidence_collection', 'resolved'))
DEFAULT 'none',
ADD COLUMN IF NOT EXISTS dispute_reason TEXT,
ADD COLUMN IF NOT EXISTS dispute_filed_by UUID REFERENCES auth.users,
ADD COLUMN IF NOT EXISTS dispute_filed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS dispute_resolved_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS dispute_resolution TEXT;
-- 3. Add cancellation tracking to bookings table
ALTER TABLE bookings
ADD COLUMN IF NOT EXISTS cancelled_by UUID REFERENCES auth.users,
ADD COLUMN IF NOT EXISTS cancellation_fee DECIMAL(10,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS cancellation_reason TEXT;
-- 4. Add refund tracking to payments table
ALTER TABLE payments
ADD COLUMN IF NOT EXISTS refund_amount DECIMAL(10,2),
ADD COLUMN IF NOT EXISTS refund_reason TEXT,
ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMPTZ;
-- 5. Create dispute evidence table
CREATE TABLE IF NOT EXISTS dispute_evidence (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
booking_id UUID REFERENCES bookings(id) ON DELETE CASCADE,
submitted_by UUID REFERENCES auth.users NOT NULL,
evidence_type TEXT NOT NULL CHECK (evidence_type IN ('photo', 'video', 'document', 'statement')),
file_url TEXT,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 6. Create booking milestones table (for partial releases)
CREATE TABLE IF NOT EXISTS booking_milestones (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
booking_id UUID REFERENCES bookings(id) ON DELETE CASCADE,
milestone TEXT NOT NULL CHECK (milestone IN ('loading', 'in_transit', 'unloading', 'completed')),
release_percentage DECIMAL(5,2) NOT NULL CHECK (release_percentage BETWEEN 0 AND 100),
release_amount DECIMAL(10,2),
completed_at TIMESTAMPTZ DEFAULT NOW(),
confirmed_by UUID REFERENCES auth.users
);
-- 7. Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_payments_escrow_status ON payments(escrow_status);
CREATE INDEX IF NOT EXISTS idx_payments_held_until ON payments(held_until)
WHERE escrow_status = 'held_escrow';
CREATE INDEX IF NOT EXISTS idx_bookings_dispute_status ON bookings(dispute_status)
WHERE dispute_status != 'none';
CREATE INDEX IF NOT EXISTS idx_bookings_dispute_filed_at ON bookings(dispute_filed_at)
WHERE dispute_filed_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_bookings_cancelled_by ON bookings(cancelled_by)
WHERE cancelled_by IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_payments_refunded_at ON payments(refunded_at)
WHERE refunded_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_dispute_evidence_booking ON dispute_evidence(booking_id);
CREATE INDEX IF NOT EXISTS idx_booking_milestones_booking ON booking_milestones(booking_id);
-- 8. Add constraints
ALTER TABLE bookings
ADD CONSTRAINT valid_cancellation_fee CHECK (cancellation_fee >= 0),
ADD CONSTRAINT cancellation_requires_cancelled_by CHECK (
(status LIKE 'cancelled%' AND cancelled_by IS NOT NULL) OR
(status NOT LIKE 'cancelled%')
);
ALTER TABLE payments
ADD CONSTRAINT valid_refund_amount CHECK (refund_amount >= 0 AND refund_amount <= amount),
ADD CONSTRAINT held_until_future CHECK (held_until IS NULL OR held_until > created_at);
-- 9. Backfill existing records (if any)
-- Set default escrow_status for existing payments
UPDATE payments
SET escrow_status = 'held_escrow'
WHERE escrow_status IS NULL AND payment_status = 'completed';
UPDATE payments
SET escrow_status = 'refunded'
WHERE escrow_status IS NULL AND is_refunded = TRUE;
-- Set default dispute_status for existing bookings
UPDATE bookings
SET dispute_status = 'none'
WHERE dispute_status IS NULL;
-- 10. Add foreign key constraints with proper cascading
ALTER TABLE dispute_evidence
ADD CONSTRAINT fk_dispute_evidence_booking
FOREIGN KEY (booking_id) REFERENCES bookings(id) ON DELETE CASCADE;
ALTER TABLE booking_milestones
ADD CONSTRAINT fk_booking_milestones_booking
FOREIGN KEY (booking_id) REFERENCES bookings(id) ON DELETE CASCADE;
-- 11. Create trigger for auto-release scheduling
CREATE OR REPLACE FUNCTION schedule_escrow_auto_release()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.escrow_status = 'held_escrow' AND NEW.held_until IS NOT NULL THEN
-- Schedule background job to release escrow at held_until timestamp
-- (Implementation depends on your job scheduler: pg_cron, Supabase Edge Functions, etc.)
PERFORM pg_notify('escrow_release_scheduled', json_build_object(
'payment_id', NEW.id,
'release_at', NEW.held_until
)::text);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_schedule_escrow_release
AFTER UPDATE OF held_until ON payments
FOR EACH ROW
EXECUTE FUNCTION schedule_escrow_auto_release();
-- 12. Add audit columns for compliance
ALTER TABLE payments
ADD COLUMN IF NOT EXISTS last_status_change_at TIMESTAMPTZ DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS status_change_history JSONB DEFAULT '[]'::jsonb;
ALTER TABLE bookings
ADD COLUMN IF NOT EXISTS dispute_history JSONB DEFAULT '[]'::jsonb;
COMMENT ON COLUMN payments.escrow_status IS 'Current escrow state for this payment';
COMMENT ON COLUMN payments.held_until IS 'Timestamp when escrow will auto-release (if no dispute)';
COMMENT ON COLUMN bookings.dispute_status IS 'Current dispute resolution status';
COMMENT ON COLUMN bookings.cancellation_fee IS 'Fee charged to cancelling party';
COMMENT ON COLUMN dispute_evidence.evidence_type IS 'Type of evidence: photo, video, document, statement';
-- End of migrationMigration Steps:
- Test on staging database with sample data
- Backup production database before applying
- Apply schema changes during low-traffic period
- Run backfill queries to update existing records
- Verify indexes created (
\diin psql) - Test auto-release trigger with test payment
- Monitor query performance after deployment
- Update application code to use new columns/statuses
Rollback Plan:
-- If issues arise, can rollback by:
DROP TRIGGER IF EXISTS trigger_schedule_escrow_release ON payments;
DROP FUNCTION IF EXISTS schedule_escrow_auto_release();
DROP TABLE IF EXISTS booking_milestones;
DROP TABLE IF EXISTS dispute_evidence;
-- Remove added columns (if no production data relies on them)
ALTER TABLE payments DROP COLUMN IF EXISTS escrow_status;
ALTER TABLE payments DROP COLUMN IF EXISTS held_until;
-- (etc.)// Use Firebase Cloud Messaging (FCM)
src/services/notifications/
- NotificationService.ts
- Send to specific users
- Send to geofenced areas (broadcast to nearby movers)
- Rich notifications (actions: Accept/Reject)
// Notification Types
- New booking request (to movers)
- Booking accepted (to customer)
- Mover arriving soon (proximity-based)
- Payment confirmation
- Rating reminder
- Promotional offers// Intelligent Matching Algorithm
- ML model to predict best mover for each job
- Factors: rating, distance, completion rate, vehicle type
- Optimize for customer satisfaction + mover earnings
// Demand Forecasting
- Predict high-demand times/areas
- Pre-position movers (incentivize them to be in right place)
// Route Optimization
- Multi-stop bookings
- Optimize mover's daily route (like Uber Eats batching)
- Reduce empty return trips
// Fraud Detection
- Detect fake bookings
- Identify suspicious mover behavior
- Prevent rating manipulation// Earnings Optimization
- Show heat maps of high-demand areas
- Suggest optimal working hours
- Gamification (badges, leaderboards)
// Fleet Management (for moving companies)
- Manage multiple vehicles
- Dispatch system
- Driver assignment
// Advanced Scheduling
- Pre-booking (schedule moves weeks in advance)
- Recurring moves (corporate contracts)// Admin Dashboard
- Real-time bookings monitoring
- Revenue tracking
- Mover performance metrics
- Customer churn analysis
- Geographic demand heat maps
// Reporting
- Financial reports (revenue, commissions, payouts)
- Operational KPIs (avg move time, cancellation rate)
- Growth metrics (DAU, MAU, retention)src/
features/
auth/
- components/
- hooks/
- services/
quotes/
- components/
- hooks/
- services/
bookings/
- components/
- hooks/
- services/
movers/
- components/
- hooks/
- services/
payments/
- components/
- hooks/
- services/
shared/
- components/
- hooks/
- utils/
- types/
// Current: Prop drilling + React Query
// Problem: Hard to manage complex real-time state
// Solution: Add Zustand or Redux Toolkit
npm install zustand
// Example: Real-time booking state
import create from 'zustand'
interface BookingStore {
activeBooking: Booking | null
moverLocation: Location | null
subscribeToBooking: (bookingId: string) => void
unsubscribe: () => void
}
export const useBookingStore = create<BookingStore>((set) => ({
activeBooking: null,
moverLocation: null,
subscribeToBooking: (bookingId) => {
const channel = supabase
.channel(`booking:${bookingId}`)
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'bookings',
filter: `id=eq.${bookingId}`
}, (payload) => {
set({ activeBooking: payload.new as Booking })
})
.subscribe()
},
unsubscribe: () => {
supabase.removeAllChannels()
}
}))// Current: No tests (CRITICAL GAP)
// Add: Jest + React Testing Library + Playwright
// Unit Tests
src/features/pricing/__tests__/
- DynamicPricingService.test.ts
- calculateBasePrice.test.ts
// Integration Tests
src/features/bookings/__tests__/
- BookingFlow.integration.test.ts
- PaymentFlow.integration.test.ts
// E2E Tests
e2e/
- customer-booking-flow.spec.ts
- mover-acceptance-flow.spec.ts
// Target: 80% code coverage before scaling// Add Sentry for error tracking
npm install @sentry/react
// Add PostHog for product analytics
npm install posthog-js
// Add custom performance monitoring
src/lib/monitoring/
- PerformanceMonitor.ts
- Track booking completion time
- Track API latencies
- Track user funnel drop-offs
- Alert on anomalies (e.g., spike in cancellations)// Current Gaps:
- No rate limiting on API calls
- No input sanitization
- No CSRF protection
- No API key rotation
// Fixes:
- Add Supabase Edge Function rate limiting
- Add Zod schema validation on all inputs
- Add helmet.js for security headers
- Rotate API keys quarterly
- Add WAF (Cloudflare) in front of app
// Example: Input Validation
import { z } from 'zod'
const BookingSchema = z.object({
pickup: z.string().min(5).max(200),
dropoff: z.string().min(5).max(200),
scheduledTime: z.date().min(new Date()),
propertySize: z.enum(['Bedsitter', '1BR', '2BR', '3BR', '4BR', '5BR+'])
})
function validateBookingInput(input: unknown) {
return BookingSchema.parse(input) // Throws if invalid
}-
Commission on Bookings (Primary)
- 15-20% of booking value
- Tiered rates (higher volume → lower commission)
-
Premium Mover Subscriptions
- $50/month: Priority placement in search
- $100/month: Advanced analytics + lower commission (12%)
-
Insurance Upsell
- Customer pays 5% extra for full damage coverage
- You keep 2%, rest goes to insurance fund
-
Corporate Partnerships
- B2B contracts with HR depts for employee relocations
- Flat monthly fee + discounted per-move rate
-
Advertising
- Featured movers (pay for top placement)
- Partner ads (furniture stores, storage facilities)
// Dynamic Pricing Tiers
Low Demand: basePrice * 1.0 (attract customers)
Normal: basePrice * 1.2 (standard)
High Demand: basePrice * 1.5 (surge, but not too high)
Peak: basePrice * 2.0 (maximum surge, rare)
// Customer Acquisition Cost (CAC) Target: $10
// Lifetime Value (LTV) Target: $150 (10 moves at $15 commission each)
// Target LTV:CAC Ratio: 15:1- Design complete database schema (all tables above)
- Set up development/staging/production environments
- Implement feature-based folder structure
- Add unit testing framework (Jest)
- Add error monitoring (Sentry)
- Build mover registration wizard
- Implement mover profile pages
- Create booking request flow (customer → mover)
- Add basic push notifications (FCM setup)
- Integrate M-Pesa STK Push (test mode)
- Booking Conversion Rate: % of quotes → actual bookings
- Time to Book: Minutes from quote to confirmed booking
- Customer Retention: % returning for 2nd move
- NPS Score: Net Promoter Score
- Acceptance Rate: % of requests accepted by movers
- Average Earnings: Per mover per week
- Mover Churn Rate: % leaving platform per month
- Online Hours: Avg hours movers are available per day
- GMV (Gross Merchandise Value): Total booking value
- Take Rate: Your commission % of GMV
- Liquidity: Avg time for booking to be matched with mover
- Completion Rate: % of bookings that complete successfully
- 1000 active customers
- 200 verified movers
- 50 bookings per day
- $50K GMV per month
- $7.5K platform revenue per month (15% commission)
Risk: Not enough movers on platform → customers can't book
Mitigation:
- Aggressive mover recruitment (referral bonuses)
- Lower commission initially (10% for first 3 months)
- Guarantee minimum earnings per hour for early adopters
Risk: Theft, damage, fraud by movers
Mitigation:
- Mandatory background checks (CRB)
- Insurance requirement for all movers
- Escrow payment system (released after completion)
- Two-way ratings (movers can rate customers too)
- 24/7 support hotline
Risk: High CAC, slow revenue ramp
Mitigation:
- Focus on organic growth (SEO, referrals)
- Start in 1-2 neighborhoods (dense urban areas)
- Expand only after proving unit economics
Risk: Deep-pocketed competitors copy your model
Mitigation:
- Build strong mover relationships (loyalty programs)
- Focus on niche: only moving (not ride-hailing + moving)
- Build defensible moat: verified mover network, AI pricing
Risk: NTSA regulations for commercial transport, PSV licensing requirements
Mitigation:
- Consult with NTSA on platform compliance requirements
- Require all movers to have valid PSV permits (Public Service Vehicle)
- Include compliance checks in mover verification process
- Maintain updated documentation of all regulatory requirements
- Build relationships with transport authority officials
Risk: Damage to property, injuries during moves, insufficient coverage
Mitigation:
- Require comprehensive mover insurance (minimum coverage thresholds)
- Platform insurance policy as backstop for catastrophic incidents
- Clear liability terms in user agreements (who pays what in different scenarios)
- Critical: Insurance upsell (see line 1655 - 5% customer premium for full damage coverage)
- Partner with insurance providers for platform-wide coverage
Risk: Non-compliance with Kenya Data Protection Act, data breaches, GDPR violations
Mitigation:
- Conduct Data Protection Impact Assessment (DPIA)
- Appoint Data Protection Officer (DPO)
- Regular security audits and penetration testing
- Comprehensive data breach response plan
- Encrypt all PII (Personally Identifiable Information)
- Clear consent mechanisms for data collection
- User data export/deletion capabilities (data subject rights)
Risk: Customers won't join without movers, movers won't join without customers
Mitigation:
- Pre-launch: Secure 10-15 committed movers with signed contracts
- Guaranteed minimum earnings for first month (e.g., KES 30,000 minimum)
- Geo-fence initial launch to dense area (e.g., Westlands - see line 1923)
- Customer pre-registration with waitlist (build demand before launch)
- Launch with "invite-only" beta (controlled growth, ensure supply matches demand)
- Double-side incentives: customer referral bonus + mover referral bonus
- "Designing Data-Intensive Applications" by Martin Kleppmann
- Supabase Realtime docs: https://supabase.com/docs/guides/realtime
- "Platform Revolution" by Parker, Van Alstyne, Choudary
- "Blitzscaling" by Reid Hoffman
- React Native (consider switching from Capacitor for better performance)
- "Mobile Design Patterns" by Theresa Neil
- Safaricom M-Pesa Daraja API docs
- "Designing Secure Payment Systems" courses on Pluralsight
- Kenya-First: Deep local knowledge (M-Pesa, local regulations)
- Niche Focus: Only moving (not trying to be everything)
- AI Pricing: Better price transparency vs. traditional movers
- Verified Network: Rigorous mover screening
- "The Uber of Moving" is baseline expectation
- Your Unique Value Prop: "Transparent, AI-Powered Moving with Verified Professionals"
- Marketing Angle: "No more haggling, no surprises, insurance included"
Your current app is production-ready for a quote generation service. Good work on legal compliance and basic features.
- Two-sided marketplace (biggest gap)
- Real-time infrastructure (GPS, notifications)
- Payment automation (M-Pesa critical)
- Dynamic pricing (AI/ML required)
- Comprehensive testing (cannot scale without this)
Option A - Fast Growth: Raise seed round ($200K-500K), hire 2-3 engineers, execute 12-month roadmap
Option B - Bootstrap: Focus on Phase 1 (marketplace), launch in 1 neighborhood, prove model, then raise
My Recommendation: Option B (Bootstrap)
- Validate demand before scaling infrastructure
- Prove unit economics in small market first
- Avoid premature optimization (don't build real-time tracking until you have 100 daily bookings)
- Prioritize database schema redesign (foundation for everything)
- Build mover onboarding flow (solve supply-side first)
- Implement basic booking flow (no real-time yet, just request → accept)
- Launch in 1 area (e.g., Westlands, Nairobi)
- Get 20 verified movers + 100 customers (prove model)
- Then build real-time features
Final Thought: You're building in a massive market (moving is $18B in Kenya). Your MVP is solid. Focus on marketplace dynamics first, technology scaling second. Uber didn't start with real-time tracking—they started with "get a black car on-demand." Build your core loop first, optimize later.
Good luck! 🚀
Document prepared by AI Strategic Consultant
For questions or implementation guidance, feel free to ask.