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