Build a ticket booking system by modeling routes with scheduled departures, tracking seat inventory per departure, collecting payment via Paystack before confirming the booking, and issuing e-tickets after webhook confirmation. Use a seat lock with expiry during checkout to prevent double-booking under concurrent load.
Tables: routes (origin, destination, operator, distance_km), departures (route_id, departs_at, vehicle_type, total_seats, available_seats, base_price), bookings (departure_id, passenger_name, passenger_email, passenger_phone, seat_number, amount_paid, status, paystack_ref, ticket_code).
async function searchRoutes(origin, destination, date) {
return db.query(
`SELECT d.*, r.origin, r.destination, r.operator
FROM departures d
JOIN routes r ON d.route_id = r.id
WHERE r.origin = $1 AND r.destination = $2
AND DATE(d.departs_at) = $3
AND d.available_seats > 0
ORDER BY d.departs_at`,
[origin, destination, date]
);
}
async function initiateBooking(departureId, passenger, seats) {
var departure = await db.departures.findById(departureId);
if (departure.available_seats < seats) throw new Error('Not enough seats');
var amount = departure.base_price * seats;
var ticketCode = 'TKT-' + Math.random().toString(36).slice(2, 10).toUpperCase();
var reference = 'BUS-' + Date.now();
var booking = await db.bookings.create({
departure_id: departureId,
passenger_name: passenger.name,
passenger_email: passenger.email,
passenger_phone: passenger.phone,
seats_count: seats,
amount_paid: amount,
status: 'pending',
paystack_ref: reference,
ticket_code: ticketCode,
});
// Temporarily hold seats
await db.departures.decrement(departureId, 'available_seats', seats);
var res = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
email: passenger.email,
amount,
currency: 'NGN',
reference,
metadata: { booking_id: booking.id, route: departure.route_id, departs_at: departure.departs_at },
}),
});
return (await res.json()).data.authorization_url;
}
Seats are decremented immediately on booking initiation (temporary hold). If payment does not complete within 15 minutes, a cron job restores the seats and cancels the pending booking.
if (event.event === 'charge.success') {
var bookingId = event.data.metadata.booking_id;
var booking = await db.bookings.findById(bookingId);
if (booking.status !== 'pending') return res.sendStatus(200);
await db.bookings.update(bookingId, { status: 'confirmed', paystack_txn_id: event.data.id });
await sendEticketEmail(booking); // PDF attachment with ticket code + QR
}
// Cancellation and refund
async function cancelBooking(bookingId, reason) {
var booking = await db.bookings.findById(bookingId);
var departure = await db.departures.findById(booking.departure_id);
var hoursUntilDeparture = (new Date(departure.departs_at) - new Date()) / 3600000;
var refundAmount = 0;
if (hoursUntilDeparture > 48) refundAmount = booking.amount_paid;
else if (hoursUntilDeparture > 24) refundAmount = Math.floor(booking.amount_paid * 0.5);
if (refundAmount > 0) {
await fetch('https://api.paystack.co/refund', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction: booking.paystack_ref, amount: refundAmount }),
});
}
await db.bookings.update(bookingId, { status: 'cancelled', cancel_reason: reason });
await db.departures.increment(booking.departure_id, 'available_seats', booking.seats_count);
}
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
See build a cinema ticketing system for the same seat-locking and e-ticket pattern in a different context.
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