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.
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.
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,
};
}
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
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.
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.
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.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course