Makeja Homes
A multi-tenant SaaS where every property company gets its own isolated Postgres schema: leases signed online, bills generated in one click, payments reconciled automatically, and an AI assistant that knows the portfolio.
- 4,500+tenants
- 5,000+units managed
- 3,000+digital leases
- 80+client companies
The problem
- Kenyan landlords run portfolios on WhatsApp groups, screenshots as payment proof, and spreadsheets with water readings done by hand.
- Off-the-shelf tools are built for other markets: no water sub-meter billing, no KRA tax rules, no M-Pesa, no caretaker or storekeeper roles.
- Property companies also need hard data isolation: one company's tenants can never leak into another's dashboard.
Architecture
Key decisions
Schema-per-tenant, not a tenant_id column
Each company's data sits in its own schema, so a missed WHERE clause cannot leak another company's tenants.
Provision in SQL, not prisma db push
Vercel's serverless filesystem is read-only, so the CLI cannot run at signup. A 577-line provisioner creates the schema, enums and every table directly.
Reconcile on read instead of a cron
Tenants who abandon Paystack checkout never hit the callback, leaving payments stuck as PENDING. Stale ones are now verified with Paystack whenever anyone views payments.
Two-layer AI memory
Raw chat history would blow the context window within weeks. Njiti keeps the history for browsing and injects only distilled, durable company facts into prompts.
Write it down: DECISIONS.md
Non-obvious calls (enum sequencing in Postgres, raw SQL over prisma migrate) are logged with what was rejected and why.
Real code
export function buildTenantUrl(schemaName: string): string {
// Neon's pgbouncer pooler strips the `options=` parameter, so search_path
// is never set and all queries silently hit the public schema instead.
const direct = (process.env.DIRECT_DATABASE_URL || /* … */ '').replace('-pooler.', '.')
// … strip any existing schema= / options= params
return `${base}${sep}options=--search_path%3D${schemaName}`
}
export function getCachedClient(schemaName: string): PrismaClient {
const existing = cache.get(schemaName)
if (existing) {
cache.delete(schemaName) // refresh recency
cache.set(schemaName, existing)
return existing
}
// … create client, then evict the least-recently-used one past the cap
}export async function sweepAllTenantSchemas(opts: { force?: boolean } = {}) {
const master = getMasterPrisma()
await ensureLedger(master)
await master.$executeRawUnsafe(`SELECT pg_advisory_lock(${ADVISORY_LOCK_KEY})`)
try {
const schemas = await listTenantSchemas(master)
for (const schema of schemas) {
results.push(await runTenantMigrations(schema, opts))
}
// …
} finally {
await master.$executeRawUnsafe(`SELECT pg_advisory_unlock(${ADVISORY_LOCK_KEY})`)
}
}const hash = crypto
.createHmac("sha512", process.env.PAYSTACK_SECRET_KEY!)
.update(body)
.digest("hex")
const hashBuf = Buffer.from(hash, "hex")
const sigBuf = signature ? Buffer.from(signature, "hex") : Buffer.alloc(0)
const signatureValid =
hashBuf.length === sigBuf.length && crypto.timingSafeEqual(hashBuf, sigBuf)
// …
await db.$transaction(async (tx) => {
// mark COMPLETED and credit the bill or deposit together …
})const stale = await db.$queryRawUnsafe(`
SELECT id, COALESCE("referenceNumber", reference) AS ref
FROM payments
WHERE status::text = 'PENDING'
AND "paymentMethod"::text = 'PAYSTACK'
AND "createdAt" < NOW() - INTERVAL '2 minutes'
LIMIT 20`)
for (const row of stale) {
const ps = await fetch(
`https://api.paystack.co/transaction/verify/${encodeURIComponent(row.ref)}`,
{ headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` } })
// Paystack says it never succeeded: mark FAILED. A real success is left
// to the full verify path (bill matching, wallet credit, receipt) …
}const BLOCKLIST_TTL_SEC = 25 * 60 * 60 // JWT maxAge is 24h
export async function revokeToken(jti: string) {
await kvSet(`blocklist:${jti}`, '1', BLOCKLIST_TTL_SEC)
}
export async function isRevoked(jti: string) {
return kvHas(`blocklist:${jti}`)
}How it was verified
- Billing guard refuses bills or water readings for months before a tenant moved in or a unit existed.
- Integer minor units for listing money, one rounding rule for the whole path.
- Kenya tax module encodes MRI withholding, contractor withholding, VAT threshold, county land rates and stamp duty.
- Paystack webhook: constant-time signature check, idempotent, money credited in one transaction.
- Rate limiting, CSRF double-submit plus Sec-Fetch-Site checks, bcrypt, Turnstile on login and password reset.
Results
- 4,500+ tenants and 5,000+ units under management.
- 3,000+ leases signed digitally, 80+ client companies.
- Three products on one login: MANAGE, FIND (public listings) and BUILD (verified contractor marketplace).
- 407 API routes and 200 pages, with every tenant schema kept current by the migration runner.
What I'd do next
- A direct M-Pesa STK push rail alongside Paystack.

