Bonaventure OgetoBy Bonaventure Ogeto|

Build a Sports Club Subscription System

Build a sports club subscription system by creating Paystack Plans for each membership tier (junior/senior/family), subscribing members via the transaction initialize endpoint, issuing a QR membership card on subscription.create, and gating facility access and session bookings behind active subscription status. Handle grace periods on invoice.payment_failed before suspending access.

Membership Tiers and Enrollment

Tables: membership_plans (name, tier, monthly_fee, annual_registration_fee, max_dependants, paystack_plan_code), members (name, email, phone, dob, photo_url, membership_number, plan_id, paystack_subscription_code, status, card_qr_code, access_suspended_at), training_sessions (coach_id, name, sport, day_of_week, time, max_participants, location), session_bookings (member_id, session_id, date, status).

var QRCode = require('qrcode');

async function enrollMember(memberEmail, memberData, planName) {
  var plan = await db.membershipPlans.findByName(planName);

  // Collect annual registration fee first (one-time)
  var regReference = 'REG-' + Date.now();
  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: memberEmail,
      amount: plan.annual_registration_fee,
      currency: 'NGN',
      reference: regReference,
      // After registration fee, redirect to monthly plan setup
      callback_url: 'https://yourclub.com/enroll/subscription?email=' + memberEmail + '&plan=' + planName,
      metadata: { step: 'registration', email: memberEmail, plan_name: planName, ...memberData },
    }),
  });
  return (await res.json()).data.authorization_url;
}

// After registration fee paid, set up monthly subscription
if (event.event === 'charge.success' && event.data.metadata.step === 'registration') {
  var meta = event.data.metadata;
  var memberNumber = 'CLB-' + new Date().getFullYear() + '-' + Math.floor(Math.random() * 9000 + 1000);
  var member = await db.members.create({
    ...meta, membership_number: memberNumber, plan_id: meta.plan_id, status: 'pending_subscription',
  });
  // Redirect to Paystack subscription setup is handled via callback_url
}

// Monthly subscription created
if (event.event === 'subscription.create') {
  var meta = event.data.metadata;
  var member = await db.members.findByEmail(meta.email);

  var qrData = JSON.stringify({ member_id: member.id, membership_number: member.membership_number });
  var qrCode = await QRCode.toDataURL(qrData);

  await db.members.update(member.id, {
    paystack_subscription_code: event.data.subscription_code,
    status: 'active',
    card_qr_code: qrCode,
  });
  await sendMembershipCard(member.email, member.name, member.membership_number, qrCode);
}

Facility Access and Training Session Booking

// Scan QR at facility entrance
async function validateMemberAccess(qrData) {
  var data = JSON.parse(qrData);
  var member = await db.members.findById(data.member_id);
  if (!member || member.membership_number !== data.membership_number) {
    throw { status: 403, message: 'Invalid membership card.' };
  }
  if (member.status !== 'active') {
    throw { status: 403, message: 'Membership ' + member.status + '. Please pay outstanding dues.' };
  }
  await db.accessLog.create({ member_id: member.id, accessed_at: new Date() });
  return { valid: true, name: member.name, plan: member.plan_name };
}

// Book a training session
async function bookSession(memberId, sessionId, date) {
  var member = await db.members.findById(memberId);
  if (member.status !== 'active') throw new Error('Active membership required to book sessions');

  var session = await db.trainingSessions.findById(sessionId);
  var booked = await db.sessionBookings.countBySessionAndDate(sessionId, date);
  if (booked >= session.max_participants) throw new Error('Session is full');

  await db.sessionBookings.create({ member_id: memberId, session_id: sessionId, date, status: 'booked' });
  await sendBookingConfirmation(member.phone, session.name, date, session.time, session.location);
}

// Subscription payment failed — grace period
if (event.event === 'invoice.payment_failed') {
  var code = event.data.subscription.subscription_code;
  var member = await db.members.findBySubscription(code);
  await db.members.update(member.id, { status: 'grace_period', access_suspended_at: addDays(new Date(), 5) });
  await sendSMS(member.phone, 'Club subscription payment failed. Access continues for 5 days. Pay now to avoid suspension.');
}

if (event.event === 'subscription.disable') {
  var code = event.data.subscription_code;
  var member = await db.members.findBySubscription(code);
  if (member) await db.members.update(member.id, { status: 'lapsed' });
}

Learn More

See build a gym membership app for a simpler single-tier membership model without session booking or family plans.

Sign up for the McTaba newsletter

Key Takeaways

  • Create one Paystack Plan per membership tier — link members to the correct plan at enrollment.
  • Issue a QR membership card on subscription.create that is scanned at the facility entrance.
  • Suspend facility access after a 5-day grace period on invoice.payment_failed.
  • Support annual registration fees (one-time) in addition to monthly subscription dues.
  • Track session attendance per member to identify at-risk members before they churn.

Frequently Asked Questions

How do I handle family memberships where multiple people share one plan?
Add a dependants table linked to a primary member. The primary member subscribes to the Family plan; dependants are added for free up to the plan limit (e.g., max 4 family members). Each dependant gets their own QR card linked to the primary member's subscription. When the primary member's subscription is suspended, all dependant access is suspended simultaneously.
Can I sell guest day passes alongside memberships?
Yes. Build a guest pass purchase flow as a one-time Paystack charge (not a Plan). On charge.success, generate a time-limited QR code valid for the day only (set an expires_at field on the access pass). At the facility scanner, check both member QR codes and guest pass QR codes. Guest passes help visitors experience the club before committing to membership.
How do I manage tournament registration fees separately from monthly dues?
Tournament registrations are one-time charges, not subscription renewals. Create a tournaments table with registration_fee and deadline fields. Require active membership to register (gate behind status: "active" check). Process tournament fees as separate Paystack transactions — this keeps tournament revenue clearly separated from membership dues in your reporting.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon