Documentation is currently in beta. Report issues →
GuidesPayment Integration

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 confirmation

Configuration

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

  1. Go to Paystack Dashboard → Settings → API Keys & Webhooks
  2. Add webhook URL: https://y3nko.travel/api/webhooks/paystack
  3. 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 NumberResult
4084 0840 8408 4081Successful payment
4084 0840 8408 4092Failed payment

Test Card Details:

  • CVV: 408
  • Expiry: Any future date
  • PIN: 0000
  • OTP: 123456

Test Mobile Money

NetworkPhone NumberOTP
MTN0551234987123456

Local Webhook Testing

Use ngrok to receive webhooks locally:

# Install ngrok
npm install -g ngrok
 
# Expose local server
ngrok http 3000

Then:

  1. Copy the ngrok URL (e.g., https://abc123.ngrok.io)
  2. Add as webhook URL in Paystack Dashboard
  3. Make a test payment
  4. 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