Bonaventure OgetoBy Bonaventure Ogeto|

Building a Driver Payout System for a Ride Hailing App

A ride-hailing driver payout system on Paystack calculates each driver net earnings from completed trips (fare minus platform commission), aggregates them into daily or weekly settlement batches, and transfers the net amount to the driver M-Pesa wallet or bank account. Handle cash-collected trips by deducting the platform commission from the driver next payout. Use bulk transfers for scheduled settlements and single transfers for instant withdrawals.

The Driver Earnings Model

A ride-hailing driver earns money per trip. Each trip has a fare that the rider pays. The platform takes a commission (typically 15-25%), and the driver keeps the rest. But the calculation is complicated by payment method: did the rider pay digitally (card, M-Pesa) or with cash?

function calculateTripEarnings(trip) {
  var fare = trip.fareAmount; // In minor units (cents)
  var commissionRate = trip.driver.commissionRate || 0.20; // 20% platform commission
  var platformCommission = Math.round(fare * commissionRate);
  var driverNet = fare - platformCommission;

  if (trip.paymentMethod === 'cash') {
    // Driver already collected the full fare in cash
    // Platform is owed its commission
    return {
      driverNet: -platformCommission, // Negative: driver owes platform
      platformCommission: platformCommission,
      cashCollected: fare,
      digitalPayment: 0,
    };
  }

  // Digital payment: money is in the platform Paystack balance
  return {
    driverNet: driverNet, // Positive: platform owes driver
    platformCommission: platformCommission,
    cashCollected: 0,
    digitalPayment: fare,
  };
}

Cash trips are the tricky part. When a rider pays cash, the driver has the money. The platform needs to collect its commission. The standard approach: deduct the platform commission from the driver's next digital payout. If the driver only does cash trips, they accumulate a negative balance (they owe the platform), and the platform needs a process to collect.

Daily Settlement Calculation

async function calculateDailySettlement(driverId, date) {
  var trips = await db.trips.findCompletedByDriverAndDate(driverId, date);

  var totalDigitalEarnings = 0;
  var totalCashOwed = 0;
  var tripCount = 0;

  for (var i = 0; i < trips.length; i++) {
    var earnings = calculateTripEarnings(trips[i]);
    if (earnings.driverNet > 0) {
      totalDigitalEarnings += earnings.driverNet;
    } else {
      totalCashOwed += Math.abs(earnings.driverNet);
    }
    tripCount++;
  }

  // Previous outstanding balance (cash owed from prior days)
  var outstandingBalance = await db.driverBalances.getOutstanding(driverId);

  // Net settlement = digital earnings - cash owed - previous outstanding
  var netSettlement = totalDigitalEarnings - totalCashOwed - outstandingBalance;

  if (netSettlement <= 0) {
    // Driver owes more than they earned digitally
    // Carry the negative balance forward
    await db.driverBalances.setOutstanding(
      driverId,
      Math.abs(netSettlement)
    );
    return {
      driverId: driverId,
      date: date,
      tripCount: tripCount,
      digitalEarnings: totalDigitalEarnings,
      cashOwed: totalCashOwed,
      outstandingBalance: outstandingBalance,
      netSettlement: 0,
      carryForward: Math.abs(netSettlement),
      payoutNeeded: false,
    };
  }

  // Clear outstanding balance and create payout
  await db.driverBalances.setOutstanding(driverId, 0);

  return {
    driverId: driverId,
    date: date,
    tripCount: tripCount,
    digitalEarnings: totalDigitalEarnings,
    cashOwed: totalCashOwed,
    outstandingBalance: outstandingBalance,
    netSettlement: netSettlement,
    carryForward: 0,
    payoutNeeded: true,
  };
}

Batching Driver Payouts

async function processDailyDriverPayouts(date) {
  var activeDrivers = await db.drivers.findActive();
  var payouts = [];

  for (var i = 0; i < activeDrivers.length; i++) {
    var settlement = await calculateDailySettlement(activeDrivers[i].id, date);

    if (settlement.payoutNeeded && settlement.netSettlement > 0) {
      payouts.push({
        amount: settlement.netSettlement,
        recipient: activeDrivers[i].paystackRecipientCode,
        reason: 'Daily settlement - ' + date + ' (' + settlement.tripCount + ' trips)',
        reference: 'driver_' + activeDrivers[i].id + '_' + date,
      });
    }
  }

  if (payouts.length === 0) {
    console.log('No driver payouts needed for ' + date);
    return;
  }

  // Check balance
  var totalNeeded = 0;
  for (var j = 0; j < payouts.length; j++) {
    totalNeeded += payouts[j].amount;
  }

  var balance = await getAvailableBalance('KES');
  if (balance.total < totalNeeded) {
    await alertFinance('Insufficient KES balance for driver payouts. Need: '
      + (totalNeeded / 100) + ', Available: ' + (balance.total / 100));
    return;
  }

  // Execute in batches of 100
  await executeBulkTransfers(payouts, 'KES');
  console.log('Processed ' + payouts.length + ' driver payouts for ' + date);
}

Run this as a cron job in the early morning (e.g., 6 AM) for the previous day's trips. Early morning execution gives the transfers time to settle before the driver starts their next shift. For M-Pesa payouts, settlement is instant regardless of time.

M-Pesa as the Default Driver Payout Method

In Kenya, M-Pesa is the natural choice for driver payouts. It is instant (drivers see the money in seconds), available 24/7, and every driver has M-Pesa. Bank transfers are an alternative for drivers who prefer bank settlement, but M-Pesa is the default.

During driver onboarding, collect the M-Pesa phone number, validate it, and create the recipient:

async function onboardDriver(driverData) {
  // Validate phone number
  var phoneResult = validateAndNormalizePhone(driverData.phoneNumber);
  if (!phoneResult.valid) {
    throw new Error(phoneResult.error);
  }

  // Create M-Pesa recipient
  var recipient = await createMpesaRecipient(
    driverData.name,
    phoneResult.normalized
  );

  // Create driver record
  var driver = await db.drivers.create({
    name: driverData.name,
    phone: phoneResult.normalized,
    vehicleType: driverData.vehicleType,
    paystackRecipientCode: recipient.recipient_code,
    recipientType: 'mpesa',
    commissionRate: 0.20, // 20% platform commission
    status: 'active',
  });

  return driver;
}

For the full M-Pesa transfer guide, see transfers to M-Pesa wallets, Paybills, and Tills.

Instant Withdrawal Feature

async function driverInstantWithdrawal(driverId, amount) {
  var driver = await db.drivers.findById(driverId);

  // Calculate available balance
  var available = await calculateDriverAvailableBalance(driverId);
  if (amount > available) {
    throw new Error('Requested amount exceeds available balance');
  }

  // Daily limit check
  var todayWithdrawals = await db.payouts.sumTodayByDriver(driverId);
  var dailyLimit = 5000000; // 50,000 KES
  if (todayWithdrawals + amount > dailyLimit) {
    throw new Error('Daily withdrawal limit exceeded');
  }

  // Minimum check
  if (amount < 10000) { // 100 KES minimum
    throw new Error('Minimum withdrawal is 100 KES');
  }

  // Execute immediate transfer
  var reference = 'instant_' + driverId + '_' + Date.now();
  var result = await initiateTransfer(
    driver.paystackRecipientCode,
    amount,
    'Instant withdrawal',
    reference
  );

  await db.payouts.create({
    driverId: driverId,
    recipientCode: driver.paystackRecipientCode,
    amountMinorUnit: amount,
    reference: reference,
    transferCode: result.transferCode,
    status: 'processing',
    type: 'instant_withdrawal',
  });

  return { reference: reference, status: 'processing' };
}

Instant withdrawals are a competitive advantage. Drivers choose platforms that let them access their money quickly. But implement proper controls: daily limits prevent abuse, minimum amounts prevent micro-transactions that incur fees, and balance validation ensures you do not pay out more than the driver has earned.

Real-Time Driver Balance Tracking

async function getDriverBalance(driverId) {
  // Today's completed trips
  var todayTrips = await db.trips.findCompletedByDriverToday(driverId);
  var todayEarnings = 0;
  var todayCashOwed = 0;

  for (var i = 0; i < todayTrips.length; i++) {
    var earnings = calculateTripEarnings(todayTrips[i]);
    if (earnings.driverNet > 0) {
      todayEarnings += earnings.driverNet;
    } else {
      todayCashOwed += Math.abs(earnings.driverNet);
    }
  }

  // Unsettled balance from previous days
  var unsettled = await db.driverBalances.getUnsettled(driverId);

  // Today's withdrawals
  var todayWithdrawals = await db.payouts.sumTodayByDriver(driverId);

  var totalAvailable = unsettled + todayEarnings - todayCashOwed - todayWithdrawals;

  return {
    todayEarnings: todayEarnings / 100,
    todayTrips: todayTrips.length,
    todayCashOwed: todayCashOwed / 100,
    unsettledBalance: unsettled / 100,
    todayWithdrawals: todayWithdrawals / 100,
    availableForWithdrawal: Math.max(0, totalAvailable) / 100,
    currency: 'KES',
  };
}

Show this balance in the driver app. Update it after each completed trip. Drivers check their balance frequently, and seeing it grow throughout the day is motivating. The "available for withdrawal" number is what they can cash out right now. Make it prominent.

Build Products Drivers Trust

Driver payouts are the most time-sensitive transfer use case. Drivers depend on these payouts for daily living expenses. Building a reliable, fast payout system is not just good engineering; it is how you attract and retain drivers.

The McTaba bootcamp covers building real marketplace products, including the payment infrastructure that makes them work.

Create a free McTaba account

Key Takeaways

  • Trip-based earnings require per-trip calculation: fare amount minus platform commission equals driver net for that trip.
  • Cash-collected trips create a negative balance. The driver collected cash from the rider, so the platform commission is deducted from their next digital payout.
  • M-Pesa is the preferred payout method for drivers in Kenya because it is instant and drivers can access the money immediately.
  • Daily settlements keep drivers happy. Calculate the day earnings at midnight, batch the payouts, and execute in the early morning.
  • Instant withdrawal is a competitive feature. Let drivers cash out their available balance at any time with daily limits.
  • Real-time balance tracking shows drivers their current earnings, pending settlements, and available withdrawal amount throughout the day.

Frequently Asked Questions

How do I handle drivers who only do cash trips and owe the platform money?
Track the outstanding balance (platform commission owed) and deduct it from future digital payouts. If the balance grows too large, disable the driver from accepting cash trips until the balance is cleared. Some platforms set a maximum outstanding balance threshold.
Should I pay drivers daily or weekly?
Daily is better for driver retention. Drivers prefer frequent, smaller payouts over waiting a week. If operational overhead is a concern, start with weekly and add daily as a premium feature or once your automation is mature. In markets with M-Pesa (like Kenya), daily payouts are near-zero effort because M-Pesa transfers are instant.
How do I handle tips in the payout calculation?
Tips paid digitally should be passed through to the driver in full (no platform commission on tips). Add the tip amount to the driver net for the trip. Tips paid in cash are already in the driver hands and do not need to be included in the payout calculation.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon