Sites Web12 min de lecture

Gestion locative SaaS Afrique : architecture multi-tenant 2026

Mohamed Bah·Fondateur, Kolonell
15 mai 2026
Partager :
Gestion locative SaaS Afrique : architecture multi-tenant 2026

Gestion locative SaaS Afrique : architecture multi-tenant 2026

Sites Web

La gestion locative manuelle au Sénégal ou en Côte d'Ivoire est un cauchemar : Excel pour les loyers, WhatsApp pour les communications, paperasse pour les baux, tournées physiques pour collecte. Au-delà de 30-50 logements gérés, un SaaS dédié devient indispensable.

TL;DR

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

- Core features : baux, états des lieux, loyers récurrents, charges, tickets maintenance.

- Marché Afrique francophone : ~200K+ propriétaires gérant 5-200 biens en 2026.

Architecture globale

`

[Locataire] [Propriétaire / gestionnaire]

↓ ↓

[App locataire] [Dashboard gestionnaire]

↓ ↓

[Backend SaaS multi-tenant]

[Postgres + Redis]

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

↓ ↓

[Wave / OM] [Brevo + WhatsApp]

`

Étape 1 — modèle de données multi-tenant

`prisma

model Organization {

id String @id @default(cuid())

name String // "Cabinet Diallo Gestion"

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 // APT / HOUSE / VILLA / COMMERCIAL

surface Int

rentAmount Int // XOF mensuel

charges Int // mensuel

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 // 1, 5, 10 du mois

status LeaseStatus // ACTIVE / TERMINATED / EXPIRED

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 // CNI

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 // "2026-05"

amount Int

charges Int

status PaymentStatus // PENDING / PAID / LATE / PARTIAL

paidAt DateTime?

paymentMethod String? // wave | orange_money | cash | bank_transfer

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 // PLUMBING / ELECTRICAL / PAINT / OTHER

description String

status TicketStatus // OPEN / IN_PROGRESS / RESOLVED

reportedAt DateTime @default(now())

resolvedAt DateTime?

cost Int?

}

model Inventory {

id String @id @default(cuid())

leaseId String @unique

type String // ENTRY / EXIT

date DateTime

rooms Json // structure JSON détaillée

pdfUrl String?

signedTenantUrl String?

signedOwnerUrl String?

}

`

Étape 2 — multi-tenancy strict

Tous les queries doivent filtrer par organizationId. Plugin Prisma global :

`ts

// lib/prisma-tenant.ts

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

export function tenantPrisma(organizationId: string) {

return new PrismaClient().$extends({

query: {

$allModels: {

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

// Skip pour Organization elle-même + User auth

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

return query(args);

}

// Inject filtre WHERE

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

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

}

// Inject CREATE

Besoin d'un site web professionnel ?

Kolonell crée des sites web qui attirent des clients, optimisés pour le marché sénégalais. Devis gratuit en 2 minutes.

if (operation === 'create') {

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

}

return query(args);

},

},

},

});

}

`

Sans ce middleware, fuite de données entre tenants = catastrophe.

Étape 3 — collecte loyers automatisée Wave / OM

Cron mensuel (le 1er du mois) :

`ts

// jobs/generate-monthly-rent-payments.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) {

// 1. Créer RentPayment

const payment = await prisma.rentPayment.create({

data: {

organizationId: lease.organizationId,

leaseId: lease.id,

month: monthKey,

amount: lease.rentAmount,

charges: lease.charges,

status: 'PENDING',

},

});

// 2. Notifier locataire WhatsApp + email

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,

],

});

}

}

`

Cron tous les 5 jours après échéance pour relances :

`ts

// J+5, J+10, J+15 après date due

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);

}

`

Étape 4 — état des lieux numérique

`tsx

// app/leases/[id]/inventory/[type]/page.tsx

'use client';

import { useState } from 'react';

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

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

return (

État des lieux d'{type === 'ENTRY' ? 'entrée' : 'sortie'}

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

key={i}

room={room}

onUpdate={(updated) => updateRoom(i, updated)}

/>

))}

);

}

function RoomEntry({ room, onUpdate }) {

return (

État

Photos

Notes