Bonaventure OgetoBy Bonaventure Ogeto|

Build a Waste Collection Subscription Service

Build a waste collection subscription by creating Paystack Plans for monthly billing, subscribing customers via the transaction initialize endpoint, scheduling collections when subscription.create fires, suspending collections when invoice.payment_failed enters the grace period, and resuming service when the next invoice.create success event arrives.

Subscription Plans and Collection Scheduling

Tables: collection_plans (name, frequency, collections_per_month, price, paystack_plan_code), subscribers (name, phone, email, address, zone_id, plan_id, paystack_subscription_code, status, next_collection_date), collections (subscriber_id, driver_id, scheduled_date, status, completed_at, notes).

var PLANS = {
  weekly:   { collections: 4, price: 200000, label: 'Weekly — 4 collections/month' },    // NGN 2,000/month
  biweekly: { collections: 2, price: 120000, label: 'Biweekly — 2 collections/month' },  // NGN 1,200/month
  monthly:  { collections: 1, price: 70000,  label: 'Monthly — 1 collection/month' },    // NGN 700/month
};

async function subscribe(customerEmail, customerId, planName, address, zoneId) {
  var plan = await db.collectionPlans.findByName(planName);

  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: customerEmail, amount: plan.price,
      plan: plan.paystack_plan_code,
      metadata: { customer_id: customerId, plan_id: plan.id, zone_id: zoneId },
    }),
  });
  return (await res.json()).data.authorization_url;
}

// First subscription confirmed
if (event.event === 'subscription.create') {
  var meta = event.data.metadata;
  await db.subscribers.update(meta.customer_id, {
    plan_id: meta.plan_id,
    zone_id: meta.zone_id,
    paystack_subscription_code: event.data.subscription_code,
    status: 'active',
  });
  await scheduleCollectionsForCycle(meta.customer_id, meta.plan_id);
}

// Each billing cycle renewal
if (event.event === 'invoice.create' && event.data.status === 'success') {
  var code = event.data.subscription.subscription_code;
  var subscriber = await db.subscribers.findBySubscription(code);
  if (subscriber) {
    await db.subscribers.update(subscriber.id, { status: 'active' });
    await scheduleCollectionsForCycle(subscriber.id, subscriber.plan_id);
  }
}

async function scheduleCollectionsForCycle(subscriberId, planId) {
  var plan = await db.collectionPlans.findById(planId);
  var subscriber = await db.subscribers.findById(subscriberId);
  var today = new Date();

  for (var i = 0; i < plan.collections_per_month; i++) {
    var collectionDate = getNextCollectionDay(subscriber.zone_id, today, i);
    await db.collections.create({
      subscriber_id: subscriberId, scheduled_date: collectionDate, status: 'scheduled',
    });
  }
}

Driver Routes and Service Suspension

// Build daily route for each zone
async function buildDriverRoute(zoneId, date) {
  var collections = await db.collections.findByZoneAndDate(zoneId, date);
  var sortedByAddress = collections.sort(function(a, b) {
    return a.subscriber.address_order - b.subscriber.address_order;
  });
  return sortedByAddress;
}

// Driver marks collection complete
async function markCollected(driverId, collectionId, notes) {
  await db.collections.update(collectionId, {
    driver_id: driverId, status: 'completed',
    completed_at: new Date(), notes: notes || '',
  });
}

// Failed payment — enter grace period then suspend
if (event.event === 'invoice.payment_failed') {
  var code = event.data.subscription.subscription_code;
  var subscriber = await db.subscribers.findBySubscription(code);
  await db.subscribers.update(subscriber.id, { status: 'grace_period' });

  // Let current scheduled collections complete within 7 days
  var graceCutoff = new Date();
  graceCutoff.setDate(graceCutoff.getDate() + 7);
  await sendSMS(subscriber.phone,
    'Payment failed. Collections continue for 7 days. Pay to keep service: yourapp.com/billing'
  );
}

// Cron: suspend after grace period
async function suspendGraceExpired() {
  var graceDeadline = new Date();
  graceDeadline.setDate(graceDeadline.getDate() - 7);

  var gracePeriodSubs = await db.subscribers.findAll({ status: 'grace_period', updated_at_lt: graceDeadline });
  for (var sub of gracePeriodSubs) {
    await db.subscribers.update(sub.id, { status: 'suspended' });
    await db.collections.cancelFuture(sub.id);
    await sendSMS(sub.phone, 'Service suspended due to non-payment. Pay to restore: yourapp.com/billing');
  }
}

if (event.event === 'subscription.disable') {
  var code = event.data.subscription_code;
  var subscriber = await db.subscribers.findBySubscription(code);
  if (subscriber) {
    await db.subscribers.update(subscriber.id, { status: 'cancelled' });
    await db.collections.cancelFuture(subscriber.id);
  }
}

Learn More

See build a water delivery subscription service for the same recurring billing and route management pattern applied to water delivery.

Sign up for the McTaba newsletter

Key Takeaways

  • Use Paystack Plans for monthly recurring billing — one plan per collection frequency (weekly, biweekly, monthly).
  • Tie collection scheduling directly to billing: invoice.create success = schedule next collection.
  • Suspend service after a 7-day grace period on failed payment — resume automatically when payment clears.
  • Group collections by zone and day to build efficient driver routes.
  • Track missed collections and credit customers or reschedule to maintain service quality.

Frequently Asked Questions

How do I handle customer complaints about missed collections?
Build a complaints flow: customers tap "We missed a collection" in the app or text a shortcode. Log the complaint with date and subscriber details. The driver lead reviews and schedules a makeup collection within 48 hours. Offer a one-day credit (prorated from the monthly fee) for each confirmed missed collection. Track missed collection rate by driver and zone as a service quality metric.
Can I support businesses with higher collection frequency and volume?
Yes. Create commercial plans with daily or custom frequency collection, priced by bin size or tonnage. Commercial clients often pay monthly via bank transfer rather than card — for this, generate a Paystack payment link and send it to their accounts payable contact monthly. Track commercial contracts separately from residential subscriptions in your database.
How do I track which waste went to which recycling center or dumpsite?
Add a disposal_site_id to the collections table. Drivers select the drop-off location after each route. Track tonnage per disposal site for environmental reporting. If you operate a recycling stream (paper, plastic), record the material type at pickup and track the recycling diversion rate per zone. This data is valuable for corporate ESG reporting partnerships.

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