Websites12 min read

Rental management SaaS Africa: 2026 multi-tenant architecture

Mohamed Bah·Fondateur, Kolonell
May 15, 2026
Share:
Rental management SaaS Africa: 2026 multi-tenant architecture

Rental management SaaS Africa: 2026 multi-tenant architecture

Websites

Manual rental management in Senegal or Ivory Coast is a nightmare: Excel for rents, WhatsApp for comms, paperwork for leases, physical rounds for collection. Past 30-50 managed rentals, a dedicated SaaS becomes indispensable.

TL;DR

- Stack: Next.js + multi-tenant Postgres + Wave/OM API + WhatsApp.

- Core features: leases, inventories, recurring rents, charges, maintenance tickets.

- Francophone Africa market: ~200K+ owners managing 5-200 rentals in 2026.

Global architecture

`

[Tenant] [Owner / manager]

↓ ↓

[Tenant app] [Manager dashboard]

↓ ↓

[Multi-tenant SaaS backend]

[Postgres + Redis]

┌──────────┴──────────┐

↓ ↓

[Wave / OM] [Brevo + WhatsApp]

`

Step 1 — multi-tenant data model

`prisma

model Organization {

id String @id @default(cuid())

name String

email String

plan Plan @default(STARTER)

properties Property[]

users User[]

}

model Property {

id String @id @default(cuid())

organizationId String

organization Organization @relation(fields: [organizationId], references: [id])

reference String

address String

city String

district String

type String

surface Int

rentAmount Int

charges Int

deposit Int

isOccupied Boolean

currentLeaseId String?

leases Lease[]

tickets MaintenanceTicket[]

}

model Lease {

id String @id @default(cuid())

organizationId String

propertyId String

property Property @relation(fields: [propertyId], references: [id])

tenantId String

tenant Tenant @relation(fields: [tenantId], references: [id])

startDate DateTime

endDate DateTime

rentAmount Int

charges Int

deposit Int

paymentDueDay Int

status LeaseStatus

inventoryIn Inventory? @relation("InventoryIn")

inventoryOut Inventory? @relation("InventoryOut")

payments RentPayment[]

}

model Tenant {

id String @id @default(cuid())

organizationId String

firstName String

lastName String

email String

phone String

whatsapp String?

cniNumber String

ninea String?

emergencyContact String?

leases Lease[]

}

model RentPayment {

id String @id @default(cuid())

organizationId String

leaseId String

lease Lease @relation(fields: [leaseId], references: [id])

month String

amount Int

charges Int

status PaymentStatus

paidAt DateTime?

paymentMethod String?

reference String?

@@unique([leaseId, month])

}

model MaintenanceTicket {

id String @id @default(cuid())

organizationId String

propertyId String

property Property @relation(fields: [propertyId], references: [id])

category String

description String

status TicketStatus

reportedAt DateTime @default(now())

resolvedAt DateTime?

cost Int?

}

model Inventory {

id String @id @default(cuid())

leaseId String @unique

type String

date DateTime

rooms Json

pdfUrl String?

signedTenantUrl String?

signedOwnerUrl String?

}

`

Step 2 — strict multi-tenancy

All queries must filter by organizationId. Global Prisma plugin:

`ts

import { Prisma, PrismaClient } from '@prisma/client';

export function tenantPrisma(organizationId: string) {

return new PrismaClient().$extends({

query: {

$allModels: {

async $allOperations({ args, model, operation, query }) {

if (['Organization', 'User'].includes(model ?? '')) {

return query(args);

}

if (operation.startsWith('find') || operation === 'count' || operation === 'aggregate') {

Need a professional website?

Kolonell builds websites that attract clients, optimized for the Sénégalese market. Free quote in 2 minutes.

args.where = { ...args.where, organizationId };

}

if (operation === 'create') {

args.data = { ...args.data, organizationId };

}

return query(args);

},

},

},

});

}

`

Without this middleware, cross-tenant data leak = catastrophe.

Step 3 — automated Wave/OM rent collection

Monthly cron (1st of month):

`ts

export async function generateMonthlyPayments() {

const today = new Date();

const monthKey = ${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')};

const activeLeases = await prisma.lease.findMany({

where: { status: 'ACTIVE' },

include: { property: true, tenant: true, organization: true },

});

for (const lease of activeLeases) {

const payment = await prisma.rentPayment.create({

data: {

organizationId: lease.organizationId,

leaseId: lease.id,

month: monthKey,

amount: lease.rentAmount,

charges: lease.charges,

status: 'PENDING',

},

});

const totalAmount = lease.rentAmount + lease.charges;

const paymentLink = await createWavePaymentLink({

amount: totalAmount,

currency: 'XOF',

reference: payment.id,

callbackUrl: https://app.kolonell.com/api/webhooks/rent-payment,

});

await sendWhatsApp(lease.tenant.whatsapp ?? lease.tenant.phone, {

template: 'rent_due',

params: [

lease.tenant.firstName,

monthKey,

totalAmount.toLocaleString(),

paymentLink,

],

});

}

}

`

D+5, D+10, D+15 reminders cron:

`ts

const overdue = await prisma.rentPayment.findMany({

where: {

status: 'PENDING',

month: previousMonth,

createdAt: { lt: new Date(Date.now() - 5*24*60*60*1000) },

},

include: { lease: { include: { tenant: true, property: true } } },

});

for (const p of overdue) {

await escalateOverdue(p);

}

`

Step 4 — digital inventory

`tsx

'use client';

import { useState } from 'react';

export default function InventoryWizard({ leaseId, type }) {

const [rooms, setRooms] = useState([]);

return (

{type === 'ENTRY' ? 'Move-in' : 'Move-out'} inventory

{rooms.map((room, i) => (

updateRoom(i, updated)} />

))}

);

}

function RoomEntry({ room, onUpdate }) {

return (

Condition

Photos

Notes