Build a rent collection platform by registering properties with unit details and rent amounts, linking tenants to units, and collecting monthly rent through Paystack via M-Pesa or card. Use Paystack Splits to automatically divide each payment between the landlord and the property agent. Send automated reminders before the due date and track late payments with configurable penalty rules. Disburse landlord funds via Paystack Transfers to their bank account or M-Pesa wallet.
The typical Kenyan rent collection process looks like this: the landlord or caretaker has a notebook. The tenant sends M-Pesa to the landlord's personal number or a paybill. The tenant sends a screenshot of the M-Pesa confirmation. The caretaker marks it in the notebook. At the end of the month, the caretaker reports to the landlord (or the property agent) with a list of who paid and who did not.
This works for a landlord with five units. It falls apart at twenty. And it is a mess at fifty or a hundred. Common problems:
A rent collection platform fixes all of this by digitizing the entire cycle: tenant registration, monthly billing, payment collection via Paystack, automatic commission splits, receipts, and disbursement to landlords.
// Properties (buildings)
const createPropertiesTable = `
CREATE TABLE properties (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
location VARCHAR(200) NOT NULL,
county VARCHAR(50),
landlord_id UUID REFERENCES landlords(id),
agent_id UUID REFERENCES agents(id),
agent_commission_percent INT DEFAULT 10, -- Agent commission as percentage
rent_due_day INT DEFAULT 1, -- Day of month rent is due
late_penalty_type VARCHAR(20) DEFAULT 'flat', -- 'flat', 'daily', 'none'
late_penalty_amount INT DEFAULT 0, -- In cents
late_penalty_grace_days INT DEFAULT 5, -- Days after due before penalty
total_units INT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
`;
// Units within a property
const createUnitsTable = `
CREATE TABLE units (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
property_id UUID REFERENCES properties(id),
unit_number VARCHAR(20) NOT NULL, -- 'A1', 'G5', 'Flat 12'
unit_type VARCHAR(50), -- 'bedsitter', 'one_bedroom', 'two_bedroom', 'shop'
rent_amount INT NOT NULL, -- Monthly rent in cents
deposit_amount INT, -- Security deposit
status VARCHAR(20) DEFAULT 'vacant', -- 'occupied', 'vacant', 'maintenance'
tenant_id UUID REFERENCES tenants(id),
lease_start DATE,
lease_end DATE,
UNIQUE(property_id, unit_number)
);
`;
// Tenants
const createTenantsTable = `
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
phone VARCHAR(15) NOT NULL,
email VARCHAR(200),
id_number VARCHAR(20),
emergency_contact VARCHAR(15),
created_at TIMESTAMP DEFAULT NOW()
);
`;
// Landlords
const createLandlordsTable = `
CREATE TABLE landlords (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
phone VARCHAR(15) NOT NULL,
email VARCHAR(200),
bank_name VARCHAR(100),
bank_account VARCHAR(30),
bank_code VARCHAR(20),
mpesa_number VARCHAR(15), -- Alternative: settle to M-Pesa
preferred_settlement VARCHAR(20) DEFAULT 'bank', -- 'bank' or 'mpesa'
paystack_recipient_code VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW()
);
`;
// Agents
const createAgentsTable = `
CREATE TABLE agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
phone VARCHAR(15) NOT NULL,
email VARCHAR(200),
bank_account VARCHAR(30),
bank_code VARCHAR(20),
paystack_recipient_code VARCHAR(50),
paystack_subaccount_code VARCHAR(50), -- For Paystack Splits
created_at TIMESTAMP DEFAULT NOW()
);
`;
// Monthly rent invoices
const createRentInvoicesTable = `
CREATE TABLE rent_invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
unit_id UUID REFERENCES units(id),
tenant_id UUID REFERENCES tenants(id),
month VARCHAR(7) NOT NULL, -- '2026-07'
rent_amount INT NOT NULL,
penalty_amount INT DEFAULT 0,
total_amount INT NOT NULL, -- rent + penalty
paid_amount INT DEFAULT 0,
balance INT GENERATED ALWAYS AS (total_amount - paid_amount) STORED,
status VARCHAR(20) DEFAULT 'unpaid', -- 'unpaid', 'partial', 'paid', 'overdue'
due_date DATE NOT NULL,
paid_date TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(unit_id, month)
);
`;
Key design points:
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
On the 25th of each month (or a configurable date), the system generates rent invoices for the next month. This gives the tenant a few days to prepare their payment before the due date.
// Scheduled job: generate next month's rent invoices
async function generateMonthlyInvoices() {
const nextMonth = getNextMonthString(); // e.g., '2026-08'
// Get all occupied units
const occupiedUnits = await db.query(
`SELECT u.*, p.rent_due_day, t.phone, t.first_name, t.last_name
FROM units u
JOIN properties p ON u.property_id = p.id
JOIN tenants t ON u.tenant_id = t.id
WHERE u.status = 'occupied'`
);
for (const unit of occupiedUnits) {
// Check if invoice already exists for this month
const existing = await db.query(
'SELECT id FROM rent_invoices WHERE unit_id = $1 AND month = $2',
[unit.id, nextMonth]
);
if (existing.length > 0) continue;
const dueDate = new Date(
parseInt(nextMonth.split('-')[0]),
parseInt(nextMonth.split('-')[1]) - 1,
unit.rent_due_day
);
await db.insert('rent_invoices', {
unit_id: unit.id,
tenant_id: unit.tenant_id,
month: nextMonth,
rent_amount: unit.rent_amount,
penalty_amount: 0,
total_amount: unit.rent_amount,
due_date: dueDate,
status: 'unpaid',
});
// Send SMS notification
const rentKES = (unit.rent_amount / 100).toLocaleString();
await sendSMS(
unit.phone,
`Rent invoice for ${nextMonth}: KES ${rentKES} for unit ${unit.unit_number}. Due date: ${dueDate.toLocaleDateString('en-KE')}. Pay via the portal or call ${process.env.PLATFORM_PHONE}.`
);
}
}
// Scheduled job: apply late penalties
async function applyLatePenalties() {
const today = new Date();
const overdueInvoices = await db.query(
`SELECT ri.*, u.property_id, u.unit_number,
p.late_penalty_type, p.late_penalty_amount, p.late_penalty_grace_days
FROM rent_invoices ri
JOIN units u ON ri.unit_id = u.id
JOIN properties p ON u.property_id = p.id
WHERE ri.status IN ('unpaid', 'partial', 'overdue')
AND ri.due_date < $1
AND p.late_penalty_type != 'none'`,
[today]
);
for (const invoice of overdueInvoices) {
const daysPastDue = Math.floor(
(today.getTime() - new Date(invoice.due_date).getTime()) / (1000 * 60 * 60 * 24)
);
if (daysPastDue <= invoice.late_penalty_grace_days) continue;
let penaltyAmount = 0;
if (invoice.late_penalty_type === 'flat') {
// One-time flat penalty (only apply once)
penaltyAmount = invoice.penalty_amount === 0 ? invoice.late_penalty_amount : 0;
} else if (invoice.late_penalty_type === 'daily') {
// Daily penalty: recalculate based on days past grace period
const penaltyDays = daysPastDue - invoice.late_penalty_grace_days;
penaltyAmount = (invoice.late_penalty_amount * penaltyDays) - invoice.penalty_amount;
}
if (penaltyAmount > 0) {
await db.query(
`UPDATE rent_invoices
SET penalty_amount = penalty_amount + $1,
total_amount = rent_amount + penalty_amount + $1,
status = 'overdue'
WHERE id = $2`,
[penaltyAmount, invoice.id]
);
}
}
}
The penalty logic must be transparent to tenants. When a penalty is applied, send an SMS: "Late penalty of KES X applied to your rent for Unit Y. Total now due: KES Z." Surprise penalties cause disputes. Transparent penalties, while not popular, are at least defensible.
Paystack Splits let you divide a single payment between multiple recipients automatically. For rent collection, this means the tenant pays once and the money is split between the landlord and the agent. No manual calculation. No waiting for the agent to forward the landlord's share.
First, register the agent as a Paystack subaccount:
// Register agent as Paystack subaccount (do this once)
async function createAgentSubaccount(agent) {
const response = await fetch('https://api.paystack.co/subaccount', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
business_name: agent.name,
settlement_bank: agent.bank_code,
account_number: agent.bank_account,
percentage_charge: 10, // Default, overridden per transaction
}),
});
const data = await response.json();
return data.data.subaccount_code;
}
Then, when collecting rent, include the split information:
// Collect rent with automatic agent commission split
app.post('/api/rent/pay', async (req, res) => {
const { invoiceId } = req.body;
const invoice = await db.query(
`SELECT ri.*, u.unit_number, u.property_id,
t.phone, t.email, t.first_name, t.last_name,
p.agent_commission_percent, p.name as property_name,
a.paystack_subaccount_code
FROM rent_invoices ri
JOIN units u ON ri.unit_id = u.id
JOIN tenants t ON ri.tenant_id = t.id
JOIN properties p ON u.property_id = p.id
LEFT JOIN agents a ON p.agent_id = a.id
WHERE ri.id = $1`,
[invoiceId]
);
const reference = `RENT-${invoice[0].unit_number}-${invoice[0].month}-${Date.now()}`;
const chargePayload = {
email: invoice[0].email || `${invoice[0].phone}@rent.portal`,
amount: invoice[0].balance, // Pay the outstanding balance
currency: 'KES',
reference,
mobile_money: {
phone: invoice[0].phone,
provider: 'mpesa',
},
metadata: {
invoice_id: invoice[0].id,
unit_number: invoice[0].unit_number,
property_name: invoice[0].property_name,
month: invoice[0].month,
tenant_name: `${invoice[0].first_name} ${invoice[0].last_name}`,
custom_fields: [
{ display_name: 'Property', variable_name: 'property', value: invoice[0].property_name },
{ display_name: 'Unit', variable_name: 'unit', value: invoice[0].unit_number },
{ display_name: 'Month', variable_name: 'month', value: invoice[0].month },
],
},
};
// Add split if agent exists
if (invoice[0].paystack_subaccount_code) {
chargePayload.subaccount = invoice[0].paystack_subaccount_code;
chargePayload.share = {
type: 'percentage',
value: invoice[0].agent_commission_percent,
};
// The agent gets their percentage, the rest goes to the main Paystack account (landlord)
}
const response = await fetch('https://api.paystack.co/charge', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(chargePayload),
});
const data = await response.json();
await db.insert('payments', {
reference,
invoice_id: invoiceId,
amount: invoice[0].balance,
status: 'pending',
});
res.json({
status: 'pending',
reference,
amount: invoice[0].balance,
message: 'Check your phone for the M-Pesa prompt',
});
});
With Splits, the money flows directly to the right people. The agent does not touch the landlord's money. The landlord does not have to chase the agent for the balance. Paystack handles the math and the settlement. This alone is enough reason for many property agents to adopt a platform.
Note: Paystack Splits settle the subaccount (agent) portion separately. The main account (which can be the landlord's or the platform's) receives the remainder. If the platform charges its own fee, you can structure it as a multi-way split or deduct the platform fee from the main account balance before disbursing to the landlord.
Payment reminders are the most impactful feature in a rent collection platform. They reduce late payments without anyone having to make a phone call.
// Reminder schedule (configurable per property)
const defaultReminderSchedule = [
{ daysBefore: 5, message: 'upcoming' }, // 5 days before due date
{ daysBefore: 1, message: 'tomorrow' }, // 1 day before
{ daysAfter: 0, message: 'today' }, // On due date
{ daysAfter: 3, message: 'overdue' }, // 3 days after
{ daysAfter: 7, message: 'overdue_final' }, // 7 days after (final notice)
];
async function sendRentReminders() {
const today = new Date();
const unpaidInvoices = await db.query(
`SELECT ri.*, u.unit_number, t.phone, t.first_name,
p.name as property_name
FROM rent_invoices ri
JOIN units u ON ri.unit_id = u.id
JOIN tenants t ON ri.tenant_id = t.id
JOIN properties p ON u.property_id = p.id
WHERE ri.status IN ('unpaid', 'partial', 'overdue')`
);
for (const invoice of unpaidInvoices) {
const dueDate = new Date(invoice.due_date);
const daysDiff = Math.floor(
(dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
);
const balanceKES = (invoice.balance / 100).toLocaleString();
let message = null;
if (daysDiff === 5) {
message = `Hi ${invoice.first_name}, your rent of KES ${balanceKES} for ${invoice.unit_number} is due on ${dueDate.toLocaleDateString('en-KE')}. Pay via the portal to avoid late penalties.`;
} else if (daysDiff === 1) {
message = `Reminder: Rent of KES ${balanceKES} for ${invoice.unit_number} is due tomorrow. Pay via M-Pesa through the portal.`;
} else if (daysDiff === 0) {
message = `Rent of KES ${balanceKES} for ${invoice.unit_number} is due today. Pay now to avoid late penalties.`;
} else if (daysDiff === -3) {
message = `Your rent of KES ${balanceKES} for ${invoice.unit_number} is 3 days overdue. Late penalties may apply. Pay now.`;
} else if (daysDiff === -7) {
message = `FINAL NOTICE: Rent of KES ${balanceKES} for ${invoice.unit_number} is 7 days overdue. Please pay immediately or contact the office.`;
}
if (message) {
await sendSMS(invoice.phone, message);
await db.insert('reminder_logs', {
invoice_id: invoice.id,
type: 'rent_reminder',
sent_at: new Date(),
});
}
}
}
Reminders work because they remove the social awkwardness. Instead of the caretaker knocking on doors asking for rent, a system sends a polite SMS. The tenant pays without a confrontation. The caretaker is not the bad guy. Everyone wins.
For the final notice (7 days overdue), some platforms send the message to both the tenant and the landlord/agent, so the landlord knows the situation before deciding on next steps.
After rent is collected and the agent commission is split off, the landlord's share needs to reach their account. Paystack handles this in two ways depending on your setup:
// Monthly disbursement to landlords
async function disburseLandlordFunds(month) {
// Get all properties with their collection summary
const collections = await db.query(
`SELECT
p.id as property_id,
p.name as property_name,
l.id as landlord_id,
l.name as landlord_name,
l.paystack_recipient_code,
l.preferred_settlement,
p.agent_commission_percent,
SUM(ri.paid_amount) as total_collected,
COUNT(ri.id) as invoices_count
FROM rent_invoices ri
JOIN units u ON ri.unit_id = u.id
JOIN properties p ON u.property_id = p.id
JOIN landlords l ON p.landlord_id = l.id
WHERE ri.month = $1 AND ri.status = 'paid'
GROUP BY p.id, l.id`,
[month]
);
for (const collection of collections) {
// Calculate landlord's share (total - agent commission - platform fee)
const agentShare = Math.floor(
collection.total_collected * collection.agent_commission_percent / 100
);
const platformFee = Math.floor(collection.total_collected * 2 / 100); // 2% platform fee
const landlordShare = collection.total_collected - agentShare - platformFee;
// Ensure recipient exists
let recipientCode = collection.paystack_recipient_code;
if (!recipientCode) {
recipientCode = await createLandlordRecipient(collection.landlord_id);
}
// Initiate transfer
const transferRes = await fetch('https://api.paystack.co/transfer', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
source: 'balance',
amount: landlordShare,
recipient: recipientCode,
reason: `Rent disbursement for ${collection.property_name} - ${month}`,
}),
});
const transfer = await transferRes.json();
// Record the disbursement
await db.insert('disbursements', {
landlord_id: collection.landlord_id,
property_id: collection.property_id,
month,
total_collected: collection.total_collected,
agent_commission: agentShare,
platform_fee: platformFee,
landlord_share: landlordShare,
transfer_code: transfer.data.transfer_code,
status: 'sent',
});
// Send SMS to landlord
const shareKES = (landlordShare / 100).toLocaleString();
const totalKES = (collection.total_collected / 100).toLocaleString();
await sendSMS(
collection.landlord_phone,
`Rent disbursement for ${collection.property_name} (${month}): Total collected KES ${totalKES}. Your share: KES ${shareKES}. Transfer initiated.`
);
}
}
The landlord statement is important. Send a monthly summary showing:
This statement replaces the monthly call between the landlord and the agent where the landlord asks "How much did we collect?" and the agent gives an approximation. The statement is exact, auditable, and generated automatically.
Property managers managing ten or more buildings need vacancy data. Which units are empty? How long have they been empty? What is the monthly revenue loss?
// Move a tenant out
app.post('/api/units/:unitId/move-out', async (req, res) => {
const { unitId } = req.params;
const { moveOutDate, depositRefund } = req.body;
const unit = await db.query('SELECT * FROM units WHERE id = $1', [unitId]);
// Check for outstanding rent
const outstanding = await db.query(
`SELECT SUM(balance) as total_balance
FROM rent_invoices
WHERE unit_id = $1 AND status IN ('unpaid', 'partial', 'overdue')`,
[unitId]
);
// Update unit status
await db.query(
'UPDATE units SET status = $1, tenant_id = NULL, lease_end = $2 WHERE id = $3',
['vacant', moveOutDate, unitId]
);
// Log the vacancy
await db.insert('vacancy_log', {
unit_id: unitId,
vacant_from: moveOutDate,
previous_tenant_id: unit[0].tenant_id,
outstanding_balance: outstanding[0].total_balance || 0,
});
res.json({
unit: unit[0].unit_number,
status: 'vacant',
outstandingBalance: outstanding[0].total_balance || 0,
depositRefund,
});
});
// Vacancy report for a property
app.get('/api/properties/:propertyId/vacancies', async (req, res) => {
const { propertyId } = req.params;
const vacantUnits = await db.query(
`SELECT u.unit_number, u.unit_type, u.rent_amount,
vl.vacant_from,
EXTRACT(DAY FROM NOW() - vl.vacant_from) as days_vacant
FROM units u
LEFT JOIN vacancy_log vl ON u.id = vl.unit_id AND vl.filled_on IS NULL
WHERE u.property_id = $1 AND u.status = 'vacant'
ORDER BY vl.vacant_from ASC`,
[propertyId]
);
const monthlyRevenueLoss = vacantUnits.reduce((sum, u) => sum + u.rent_amount, 0);
res.json({
vacantUnits,
totalVacant: vacantUnits.length,
monthlyRevenueLoss,
});
});
The vacancy report is a powerful tool for property agents when talking to landlords. "Your building has 3 vacant units, losing KES 45,000 per month" is a concrete data point that drives decisions about pricing, marketing, or property improvements.
Every rent payment generates a receipt. The receipt protects the tenant (proof of payment) and the landlord (auditable record).
async function generateRentReceipt(paymentData) {
const { reference, invoiceId, amount } = paymentData;
const invoice = await db.query(
`SELECT ri.*, u.unit_number, u.rent_amount,
t.first_name, t.last_name, t.phone,
p.name as property_name, p.location
FROM rent_invoices ri
JOIN units u ON ri.unit_id = u.id
JOIN tenants t ON ri.tenant_id = t.id
JOIN properties p ON u.property_id = p.id
WHERE ri.id = $1`,
[invoiceId]
);
const receipt = {
receiptNumber: reference,
date: new Date().toISOString(),
// Tenant details
tenantName: `${invoice[0].first_name} ${invoice[0].last_name}`,
// Property details
propertyName: invoice[0].property_name,
propertyLocation: invoice[0].location,
unitNumber: invoice[0].unit_number,
// Payment details
month: invoice[0].month,
rentAmount: invoice[0].rent_amount,
penaltyAmount: invoice[0].penalty_amount,
amountPaid: amount,
previousPayments: invoice[0].paid_amount - amount,
remainingBalance: invoice[0].balance,
paymentStatus: invoice[0].balance <= 0 ? 'FULLY PAID' : 'PARTIAL PAYMENT',
};
return await createReceiptPDF(receipt);
}
Send the receipt link via SMS immediately after payment confirmation. The tenant should be able to download any past receipt from the portal at any time. When a landlord says "You did not pay March," the tenant pulls up the receipt in ten seconds.
A rent collection platform is a real product in a large market. Kenya has millions of rental properties, and the vast majority still use the notebook-and-phone-call system. Building a working alternative is a meaningful engineering project and a viable business.
The McTaba M-Pesa Integration course (KES 9,999) teaches the payment collection, splits, and transfer patterns at the core of this platform. The full fee applies as credit toward the 26-week bootcamp.
The 26-week Full-Stack Software and AI Engineering bootcamp (KES 120,000) covers the complete stack: databases, APIs, scheduling, authentication, deployment, and production-grade engineering.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course