Skip to main content

Command Palette

Search for a command to run...

Building a Multi-Tenant SaaS Backend for Nigeria: Architecture, Billing & Auth

Updated
5 min read

Building a Multi-Tenant SaaS Backend for Nigeria

A practical implementation guide covering multi-tenant architecture, Paystack subscription billing and role-based access control.

1. Multi-Tenant Database Schema

-- Shared database, tenant ID approach
-- Every table includes tenant_id, indexed

CREATE TABLE tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL,
  subdomain VARCHAR(100) UNIQUE NOT NULL,
  plan VARCHAR(50) DEFAULT 'trial',
  status VARCHAR(20) DEFAULT 'active',
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  email VARCHAR(255) NOT NULL,
  role VARCHAR(50) DEFAULT 'member', -- admin, member, billing_only
  created_at TIMESTAMP DEFAULT NOW(),
  UNIQUE(tenant_id, email)
);

CREATE TABLE customer_records (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  -- ... business data fields
  created_at TIMESTAMP DEFAULT NOW()
);

-- Critical: index every table on tenant_id
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_customer_records_tenant ON customer_records(tenant_id);

2. Tenant-Scoping Middleware (Prevents Data Leaks)

// Express middleware — runs on every authenticated request
// Makes it structurally hard to forget tenant filtering

const tenantScopeMiddleware = (req, res, next) => {
  if (!req.user || !req.user.tenantId) {
    return res.status(401).json({ error: 'No tenant context' });
  }

  // Attach a pre-scoped query builder to every request
  req.db = {
    customerRecords: {
      findMany: (where = {}) => db.customerRecords.findMany({
        where: { ...where, tenantId: req.user.tenantId }
      }),
      create: (data) => db.customerRecords.create({
        data: { ...data, tenantId: req.user.tenantId }
      }),
      update: (id, data) => db.customerRecords.update({
        where: { id, tenantId: req.user.tenantId }, // tenant check on update too
        data
      })
    }
  };

  next();
};

// Usage in routes — tenant filtering happens automatically
app.get('/api/customers', authMiddleware, tenantScopeMiddleware, async (req, res) => {
  const customers = await req.db.customerRecords.findMany();
  res.json(customers); // Only this tenant's data, guaranteed
});

3. Role-Based Access Control

const ROLES = {
  ADMIN: 'admin',
  MEMBER: 'member',
  BILLING_ONLY: 'billing_only'
};

const PERMISSIONS = {
  [ROLES.ADMIN]: ['read', 'write', 'delete', 'manage_billing', 'invite_users'],
  [ROLES.MEMBER]: ['read', 'write'],
  [ROLES.BILLING_ONLY]: ['read_billing', 'manage_billing']
};

const requirePermission = (permission) => (req, res, next) => {
  const userPermissions = PERMISSIONS[req.user.role] || [];
  if (!userPermissions.includes(permission)) {
    return res.status(403).json({ error: 'Insufficient permissions' });
  }
  next();
};

// Usage
app.delete('/api/customers/:id',
  authMiddleware,
  tenantScopeMiddleware,
  requirePermission('delete'),
  async (req, res) => {
    await req.db.customerRecords.delete(req.params.id);
    res.sendStatus(204);
  }
);

4. Paystack Subscription Billing

class NigerianSaaSBilling {
  constructor() {
    this.paystackKey = process.env.PAYSTACK_SECRET_KEY;
  }

  // Create subscription plan (one-time setup)
  async createPlan(name, amountNGN, interval) {
    const response = await fetch('https://api.paystack.co/plan', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${this.paystackKey}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        name,
        amount: amountNGN * 100, // kobo
        interval // 'monthly', 'annually'
      })
    });
    return response.json();
  }

  // Subscribe a tenant to a plan
  async subscribeTenant(tenantId, email, planCode) {
    // First create/get customer
    const customerRes = await fetch('https://api.paystack.co/customer', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${this.paystackKey}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ email })
    });
    const customer = await customerRes.json();

    // Initialize transaction with subscription plan
    const txRes = await fetch('https://api.paystack.co/transaction/initialize', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${this.paystackKey}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        email,
        amount: 0, // Plan amount used
        plan: planCode,
        metadata: { tenantId }
      })
    });

    return txRes.json();
  }

  // Handle webhook for subscription events
  async handleWebhook(event) {
    switch (event.event) {
      case 'subscription.create':
        await this.activateTenant(event.data.metadata.tenantId);
        break;

      case 'charge.success':
        await this.recordPayment(event.data);
        break;

      case 'invoice.payment_failed':
        await this.handleFailedPayment(event.data.metadata.tenantId);
        break;

      case 'subscription.disable':
        await this.scheduleDeactivation(event.data.metadata.tenantId);
        break;
    }
  }

  async handleFailedPayment(tenantId) {
    // Don't immediately suspend — Nigerian cards fail often
    // for reasons unrelated to actual non-payment
    const GRACE_PERIOD_DAYS = 3;

    await Tenant.update(
      {
        paymentStatus: 'PAST_DUE',
        gracePeriodEnds: new Date(Date.now() + GRACE_PERIOD_DAYS * 86400000)
      },
      { where: { id: tenantId } }
    );

    const tenant = await Tenant.findByPk(tenantId);
    await sendEmail(tenant.billingEmail,
      'Payment Failed — Action Required',
      `Your last payment failed. Please update your payment method within ${GRACE_PERIOD_DAYS} days to avoid service interruption.`
    );
  }

  async activateTenant(tenantId) {
    await Tenant.update(
      { status: 'active', plan: 'paid' },
      { where: { id: tenantId } }
    );
  }
}

5. Usage Analytics — Built In From Day One

class SaaSEventTracker {
  async track(tenantId, userId, eventName, properties = {}) {
    await Event.create({
      tenantId, userId, eventName, properties,
      timestamp: new Date()
    });
  }

  // Identify churn risk signals
  async getChurnRiskScore(tenantId) {
    const last30Days = new Date(Date.now() - 30 * 86400000);

    const loginCount = await Event.count({
      where: { tenantId, eventName: 'user_login', timestamp: { $gte: last30Days } }
    });

    const coreFeatureUsage = await Event.count({
      where: { tenantId, eventName: 'core_feature_used', timestamp: { $gte: last30Days } }
    });

    let riskScore = 0;
    if (loginCount < 5) riskScore += 40;
    if (coreFeatureUsage < 10) riskScore += 40;
    if (loginCount === 0) riskScore = 100;

    return { riskScore, signal: riskScore > 60 ? 'HIGH_CHURN_RISK' : 'HEALTHY' };
  }
}

Nigerian SaaS Architecture Checklist

  • [ ] Multi-tenant data isolation enforced at middleware level

  • [ ] Every database table indexed on tenant_id

  • [ ] Role-based access control implemented

  • [ ] Paystack subscription billing integrated

  • [ ] Payment failure grace period (not immediate suspension)

  • [ ] Usage event tracking from day one

  • [ ] Stateless app servers for horizontal scaling

  • [ ] Redis caching layer with tenant-scoped keys

  • [ ] Onboarding flow optimized for time-to-value

  • [ ] NDPR-compliant data handling per tenant


ZikarelHub LTD builds SaaS platforms for Nigerian founders — MVP to full multi-tenant production systems. zikarelhub.tech/saas-development-nigeria