Bonaventure OgetoBy Bonaventure Ogeto|

Build a Solar Pay-As-You-Go Payment System

Build a solar PAYG system by tracking each device's credit balance (in days of usage), generating a unique numeric unlock token on every Paystack payment (charge.success webhook), sending the token to the customer via SMS, and having the device firmware validate and apply the token to extend its active period. When the customer's total payments reach the ownership threshold, issue a final "unlock forever" token.

Device Registration and Token Generation

Tables: devices (serial_number, customer_id, model, install_date, total_cost, total_paid, status, credit_days_remaining, last_token_used), payments (device_id, amount, paystack_ref, token, token_days, sent_at, applied_at).

var crypto = require('crypto');

// Generate a deterministic but unpredictable 12-digit unlock token
function generatePaygToken(deviceSerial, paymentRef, secretKey) {
  var hmac = crypto.createHmac('sha256', secretKey);
  hmac.update(deviceSerial + '|' + paymentRef);
  var hash = hmac.digest('hex');
  // Convert first 12 hex chars to decimal — device firmware validates same way
  var tokenNum = parseInt(hash.slice(0, 12), 16);
  return (tokenNum % 900000000000 + 100000000000).toString(); // 12-digit token
}

function calculateTokenDays(amountKobo) {
  // NGN 200/day of credit
  return Math.floor(amountKobo / 20000);
}

async function initializeTopup(customerPhone, deviceId, amount) {
  var device = await db.devices.findById(deviceId);
  var customer = await db.customers.findById(device.customer_id);
  var reference = 'PAYG-' + device.serial_number + '-' + 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: customer.email,
      amount,
      currency: 'NGN',
      reference,
      metadata: { device_id: deviceId, serial: device.serial_number, customer_phone: customerPhone },
    }),
  });
  return (await res.json()).data.authorization_url;
}

// Webhook: generate token and send via SMS
if (event.event === 'charge.success') {
  var meta = event.data.metadata;
  if (!meta.device_id) return res.sendStatus(200);

  var device = await db.devices.findById(meta.device_id);
  var tokenDays = calculateTokenDays(event.data.amount);
  var token = generatePaygToken(meta.serial, event.data.reference, process.env.PAYG_TOKEN_SECRET);

  await db.payments.create({
    device_id: meta.device_id,
    amount: event.data.amount,
    paystack_ref: event.data.reference,
    token,
    token_days: tokenDays,
    sent_at: new Date(),
  });

  // Update device credit
  var newTotal = device.total_paid + event.data.amount;
  var newCredit = device.credit_days_remaining + tokenDays;
  await db.devices.update(meta.device_id, { total_paid: newTotal, credit_days_remaining: newCredit });

  // Send token via SMS
  await sendSMS(meta.customer_phone, 'Solar token: ' + token + ' (' + tokenDays + ' days). Enter on device keypad. Balance: NGN ' + (newTotal / 100));

  // Check ownership milestone
  if (newTotal >= device.total_cost) {
    var ownershipToken = generatePaygToken(meta.serial, 'FOREVER', process.env.PAYG_TOKEN_SECRET);
    await db.devices.update(meta.device_id, { status: 'owned' });
    await sendSMS(meta.customer_phone, 'Congratulations! Your solar system is now fully paid. Final unlock code: ' + ownershipToken + '. Your system is yours forever.');
  }
}

Device Status Management and Disable Flow

// Cron: track credit expiry and flag inactive devices
async function checkDeviceCredit() {
  var devices = await db.devices.findAll({ status: 'active' });
  var today = new Date();

  for (var device of devices) {
    // Deduct one day of credit per day
    var newCredit = device.credit_days_remaining - 1;

    if (newCredit <= 0) {
      await db.devices.update(device.id, { credit_days_remaining: 0, status: 'disabled' });
      var customer = await db.customers.findById(device.customer_id);
      await sendSMS(customer.phone, 'Your solar credit has expired. Top up to restore power: yourapp.com/topup');
    } else {
      await db.devices.update(device.id, { credit_days_remaining: newCredit });
      if (newCredit <= 3) { // low credit warning
        var customer = await db.customers.findById(device.customer_id);
        await sendSMS(customer.phone, 'Low solar credit: ' + newCredit + ' days remaining. Top up: yourapp.com/topup');
      }
    }
  }
}

// Field agent: register a new customer and device
async function registerCustomerDevice(customerData, serialNumber, model, totalCostNGN) {
  var customer = await db.customers.create(customerData);
  var device = await db.devices.create({
    serial_number: serialNumber,
    customer_id: customer.id,
    model,
    install_date: new Date(),
    total_cost: totalCostNGN * 100, // store in kobo
    total_paid: 0,
    status: 'active',
    credit_days_remaining: 3, // 3-day free trial on installation
  });
  await sendSMS(customer.phone, 'Welcome! Your solar system is active for 3 free days. Top up at yourapp.com/topup using device ID: ' + device.id);
  return { customer, device };
}

Learn More

See build a savings goal app with scheduled debits for card tokenization auto-debit — useful for customers who prefer automatic weekly PAYG topups.

Sign up for the McTaba newsletter

Key Takeaways

  • Generate a cryptographically derived numeric token per payment — the device validates it without internet access.
  • Send the token via SMS immediately after charge.success — the customer cannot use their device until they receive it.
  • Track cumulative payments per device to determine ownership milestone.
  • Support M-Pesa payments via Paystack (Kenya) for the unbanked customer base typical of PAYG solar.
  • Devices can be remotely disabled (via the next token request) if a customer stops paying — no repossession needed.

Frequently Asked Questions

How does the device validate the token without internet connectivity?
PAYG solar devices use a technique called HMAC-based token validation (the same principle as TOTP). The device's firmware knows the same secret key and can independently compute whether a given token is valid for that device serial number. The server and device use the same algorithm — no connectivity required. Tokens are one-time-use to prevent replay attacks.
What if the customer pays but their SMS is delayed or lost?
Store the token in your database immediately. Build a token lookup portal: the customer visits yourapp.com/token and enters their phone number or device ID to retrieve their latest token without needing to call support. Also set up a USSD shortcode (*123*deviceID#) for feature phone users who cannot browse the web.
How do I handle device transfers (customer sells to someone else)?
Add a device transfer flow: current owner initiates a transfer, new owner pays a transfer fee and registers their details. The device is unlinked from the old customer and linked to the new one. The ownership progress (total_paid) stays with the device, not the customer — the new owner benefits from previous payments made toward ownership. Document this clearly to prevent disputes.

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