Building a Multi-Tenant SaaS Backend for Nigeria: Architecture, Billing & Auth
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
