Payment Integration
Guide to implementing and testing Paystack payments in Y3NKO.
Overview
Y3NKO uses Paystack for payment processing, supporting:
- Mobile Money: MTN, Vodafone, AirtelTigo
- Cards: Visa, Mastercard
- Bank Transfer: Direct bank payment
Payment Flow
1. User completes booking form
↓
2. Booking created with PENDING status
↓
3. Payment initialized (Paystack API)
↓
4. User redirected to Paystack checkout
↓
5. User completes payment
↓
6. Paystack sends webhook
↓
7. Webhook confirms booking
↓
8. User redirected to confirmationConfiguration
Environment Variables
PAYSTACK_SECRET_KEY=sk_test_xxxxx
PAYSTACK_PUBLIC_KEY=pk_test_xxxxx
PAYSTACK_WEBHOOK_SECRET=whsec_xxxxx⚠️
Use test keys (sk_test_, pk_test_) for development. Switch to live keys for production.
Webhook Setup
- Go to Paystack Dashboard → Settings → API Keys & Webhooks
- Add webhook URL:
https://y3nko.travel/api/webhooks/paystack - Copy the webhook secret to
PAYSTACK_WEBHOOK_SECRET
Implementation
Initialize Payment
// lib/paystack.ts
interface InitializePaymentParams {
email: string
amount: number // In pesewas (GHS × 100)
reference: string
callbackUrl: string
metadata: {
bookingId: string
userId: string
listingId: string
}
}
export async function initializePayment(params: InitializePaymentParams) {
const response = 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: params.email,
amount: params.amount,
reference: params.reference,
callback_url: params.callbackUrl,
channels: ['mobile_money', 'card', 'bank'],
currency: 'GHS',
metadata: params.metadata,
}),
})
const data = await response.json()
if (!data.status) {
throw new Error(data.message || 'Payment initialization failed')
}
return {
authorizationUrl: data.data.authorization_url,
accessCode: data.data.access_code,
reference: data.data.reference,
}
}Webhook Handler
// app/api/webhooks/paystack/route.ts
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
import { prisma } from '@/lib/db'
import { sendBookingConfirmation } from '@/lib/email'
export async function POST(req: NextRequest) {
const body = await req.text()
const signature = req.headers.get('x-paystack-signature')
// Verify webhook signature
const hash = crypto
.createHmac('sha512', process.env.PAYSTACK_WEBHOOK_SECRET!)
.update(body)
.digest('hex')
if (hash !== signature) {
console.error('Invalid Paystack webhook signature')
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
const event = JSON.parse(body)
switch (event.event) {
case 'charge.success':
await handleSuccessfulPayment(event.data)
break
case 'charge.failed':
await handleFailedPayment(event.data)
break
}
return NextResponse.json({ received: true })
}
async function handleSuccessfulPayment(data: any) {
const { reference, metadata, channel, paid_at } = data
// Update payment record
await prisma.payment.update({
where: { paystackRef: reference },
data: {
status: 'COMPLETED',
method: mapChannel(channel),
paidAt: new Date(paid_at),
},
})
// Update booking status
const booking = await prisma.booking.update({
where: { id: metadata.bookingId },
data: {
status: 'CONFIRMED',
confirmedAt: new Date(),
},
include: {
user: true,
listing: true,
},
})
// Block dates in availability
await blockDates(booking.listingId, booking.checkIn, booking.checkOut, booking.reference)
// Send confirmation email
await sendBookingConfirmation(booking)
}
function mapChannel(channel: string) {
switch (channel) {
case 'mobile_money':
return 'MOBILE_MONEY'
case 'bank':
return 'BANK_TRANSFER'
default:
return 'CARD'
}
}Booking Creation with Payment
// lib/actions/bookings.ts
"use server"
export async function createBookingWithPayment(data: BookingInput) {
const user = await getCurrentUser()
if (!user) throw new Error('Unauthorized')
// Calculate pricing
const listing = await prisma.listing.findUnique({
where: { id: data.listingId }
})
const pricing = calculatePricing({
pricePerNight: Number(listing.priceGHS),
nights: calculateNights(data.checkIn, data.checkOut),
currency: 'GHS'
})
// Create booking
const booking = await prisma.booking.create({
data: {
reference: generateReference('Y3K'),
userId: user.id,
listingId: listing.id,
checkIn: new Date(data.checkIn),
checkOut: new Date(data.checkOut),
nights: pricing.nights,
adults: data.adults,
children: data.children || 0,
pricePerNight: listing.priceGHS,
subtotal: pricing.subtotal,
serviceFee: pricing.serviceFee,
taxes: pricing.taxes,
total: pricing.total,
guestName: data.guestName,
guestEmail: data.guestEmail,
guestPhone: data.guestPhone,
status: 'PENDING'
}
})
// Create payment record
await prisma.payment.create({
data: {
bookingId: booking.id,
amount: pricing.total,
currency: 'GHS',
status: 'PENDING',
paystackRef: booking.reference
}
})
// Initialize Paystack payment
const payment = await initializePayment({
email: data.guestEmail,
amount: Math.round(pricing.total * 100), // Pesewas
reference: booking.reference,
callbackUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/bookings/${booking.reference}`,
metadata: {
bookingId: booking.id,
userId: user.id,
listingId: listing.id
}
})
return {
booking,
paymentUrl: payment.authorizationUrl
}
}Pricing Calculation
// lib/pricing.ts
const SERVICE_FEE_RATE = 0.10 // 10%
const TAX_RATE = 0.125 // 12.5% VAT + Tourism Levy
export function calculatePricing(input: {
pricePerNight: number
nights: number
currency: 'GHS'
}) {
const subtotal = input.pricePerNight * input.nights
const serviceFee = Math.round(subtotal * SERVICE_FEE_RATE * 100) / 100
const taxes = Math.round(subtotal * TAX_RATE * 100) / 100
const total = Math.round((subtotal + serviceFee + taxes) * 100) / 100
return {
pricePerNight: input.pricePerNight,
nights: input.nights,
subtotal,
serviceFee,
taxes,
total,
currency: input.currency
}
}Testing
Test Cards
| Card Number | Result |
|---|---|
4084 0840 8408 4081 | Successful payment |
4084 0840 8408 4092 | Failed payment |
Test Card Details:
- CVV:
408 - Expiry: Any future date
- PIN:
0000 - OTP:
123456
Test Mobile Money
| Network | Phone Number | OTP |
|---|---|---|
| MTN | 0551234987 | 123456 |
Local Webhook Testing
Use ngrok to receive webhooks locally:
# Install ngrok
npm install -g ngrok
# Expose local server
ngrok http 3000Then:
- Copy the ngrok URL (e.g.,
https://abc123.ngrok.io) - Add as webhook URL in Paystack Dashboard
- Make a test payment
- Check terminal for webhook logs
Refunds
// lib/refunds.ts
export async function processRefund(bookingReference: string, reason: string) {
const booking = await prisma.booking.findUnique({
where: { reference: bookingReference },
include: { payments: { where: { status: 'COMPLETED' } } }
})
const payment = booking.payments[0]
if (!payment) throw new Error('No completed payment found')
// Calculate refund based on cancellation policy
const refundAmount = calculateRefundAmount(booking)
if (refundAmount <= 0) {
return { success: false, message: 'No refund applicable' }
}
// Process via Paystack
const response = 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: payment.paystackRef,
amount: Math.round(refundAmount * 100)
}),
})
const result = await response.json()
if (result.status) {
await prisma.payment.update({
where: { id: payment.id },
data: { status: 'REFUNDED' }
})
await prisma.booking.update({
where: { id: booking.id },
data: {
status: 'CANCELLED',
cancelledAt: new Date(),
cancelReason: reason
}
})
return { success: true, refundAmount }
}
return { success: false, message: result.message }
}
function calculateRefundAmount(booking: Booking): number {
const now = new Date()
const checkIn = new Date(booking.checkIn)
const daysUntilCheckIn = Math.ceil(
(checkIn.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
)
const baseAmount = Number(booking.subtotal)
// Cancellation policy
if (daysUntilCheckIn >= 7) return baseAmount // Full refund
if (daysUntilCheckIn >= 3) return baseAmount * 0.5 // 50% refund
return 0 // No refund
}Security Checklist
- Verify webhook signatures
- Never log card numbers
- Store keys in environment variables
- Validate amounts server-side
- Use unpredictable references (CUID)
- Rate limit payment endpoints
- Audit log all transactions