Building a Chama Contribution Tracker with Payments
Build a chama contribution tracker by creating a member registry, defining monthly contribution amounts, and using Paystack to collect payments via M-Pesa. Initialize transactions for each member when contributions are due, track payment status via webhooks, send SMS reminders to members who have not paid, and manage the merry-go-round payout rotation using Paystack Transfers to disburse the pot to the designated recipient each cycle.
Why Build Chama Software
Chamas (informal investment groups) are one of Kenya's most significant financial institutions. There are an estimated 300,000+ registered chamas in Kenya, managing billions of shillings. Most of them track contributions in WhatsApp groups, notebooks, or spreadsheets. The treasurer sends M-Pesa to collect money. Members argue about who paid what. The exercise book gets lost.
A digital chama tracker with integrated payments solves three problems:
- Payment tracking. Every contribution is automatically recorded when the M-Pesa payment clears. No more "I sent but it did not reflect" disputes.
- Transparency. Every member can see the treasury balance, contribution history, and payout schedule. No more suspicions about the treasurer.
- Automation. Reminders go out automatically. Payouts happen on schedule. Reports generate themselves.
The product is straightforward to build. The challenge is not the code. It is getting chamas to trust a digital system with their money. That trust comes from transparency, reliability, and a clean M-Pesa-first experience that feels as natural as sending money to the treasurer's personal number.
Architecture Overview
The system has four main components:
- Member management. Register chama members with their names, phone numbers, and roles (chairperson, treasurer, member).
- Contribution collection. Each cycle (usually monthly), the system generates contribution invoices for each member, collects payments via Paystack/M-Pesa, and tracks who has paid.
- Treasury management. Track the total balance, contributions received, payouts made, and any expenses.
- Payout rotation. For merry-go-round chamas, manage the rotation schedule and disburse the pot to the designated member using Paystack Transfers.
The Paystack integration touches two points: the Charge API for collecting contributions (money in) and the Transfers API for disbursing payouts (money out). Webhooks drive the state changes. When a contribution payment succeeds, the member is marked as paid for that cycle. When a payout transfer succeeds, the rotation advances.
Data Model
CREATE TABLE chamas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
contribution_amount_cents INTEGER NOT NULL,
contribution_interval VARCHAR(20) DEFAULT 'monthly', -- monthly, weekly, biweekly
payout_type VARCHAR(30) DEFAULT 'merry_go_round', -- merry_go_round, investment, hybrid
treasury_balance_cents BIGINT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE chama_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chama_id UUID REFERENCES chamas(id) NOT NULL,
name VARCHAR(255) NOT NULL,
phone VARCHAR(20) NOT NULL,
email VARCHAR(255),
role VARCHAR(20) DEFAULT 'member', -- chairperson, treasurer, secretary, member
rotation_order INTEGER, -- position in merry-go-round rotation
paystack_recipient_code VARCHAR(100), -- for payouts
is_active BOOLEAN DEFAULT TRUE,
joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(chama_id, phone)
);
CREATE TABLE contribution_cycles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chama_id UUID REFERENCES chamas(id) NOT NULL,
cycle_number INTEGER NOT NULL,
due_date DATE NOT NULL,
payout_recipient_id UUID REFERENCES chama_members(id), -- who gets the pot this cycle
total_expected_cents INTEGER NOT NULL,
total_collected_cents INTEGER DEFAULT 0,
status VARCHAR(20) DEFAULT 'open', -- open, closed, paid_out
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(chama_id, cycle_number)
);
CREATE TABLE contributions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cycle_id UUID REFERENCES contribution_cycles(id) NOT NULL,
member_id UUID REFERENCES chama_members(id) NOT NULL,
amount_due_cents INTEGER NOT NULL,
amount_paid_cents INTEGER DEFAULT 0,
status VARCHAR(20) DEFAULT 'pending', -- pending, partial, paid, overdue
paystack_reference VARCHAR(255),
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(cycle_id, member_id)
);
CREATE TABLE chama_payouts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cycle_id UUID REFERENCES contribution_cycles(id) NOT NULL,
recipient_id UUID REFERENCES chama_members(id) NOT NULL,
amount_cents INTEGER NOT NULL,
paystack_transfer_code VARCHAR(100),
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, success, failed
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE chama_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chama_id UUID REFERENCES chamas(id) NOT NULL,
type VARCHAR(20) NOT NULL, -- contribution, payout, expense, penalty
amount_cents INTEGER NOT NULL,
direction VARCHAR(10) NOT NULL, -- in, out
description VARCHAR(500),
reference_id UUID, -- links to contribution, payout, or expense
created_at TIMESTAMPTZ DEFAULT NOW()
);
Design notes:
- rotation_order on chama_members determines who gets the merry-go-round payout next. If member A is order 1 and member B is order 2, A gets paid in cycle 1, B in cycle 2, and so on.
- chama_transactions is a simple ledger. Every money movement (contribution received, payout made, expense paid) creates a row. The treasury balance should always equal the sum of "in" transactions minus "out" transactions.
- Partial payments. The contributions table tracks amount_due and amount_paid separately. A member who sends half their contribution shows status "partial" with amount_paid reflecting what they sent.
Contribution Collection via M-Pesa
At the start of each contribution cycle, create contribution records for every active member, then collect payments.
// Create a new contribution cycle
app.post('/api/chamas/:chamaId/cycles', async (req, res) => {
const chama = await db.query('SELECT * FROM chamas WHERE id = $1', [req.params.chamaId]);
if (!chama.rows[0]) return res.status(404).json({ error: 'Chama not found' });
// Get the latest cycle number
const lastCycle = await db.query(
'SELECT MAX(cycle_number) as last FROM contribution_cycles WHERE chama_id = $1',
[req.params.chamaId]
);
const nextCycleNumber = (lastCycle.rows[0].last || 0) + 1;
// Determine due date
const dueDate = new Date();
if (chama.rows[0].contribution_interval === 'monthly') {
dueDate.setMonth(dueDate.getMonth() + 1);
} else if (chama.rows[0].contribution_interval === 'weekly') {
dueDate.setDate(dueDate.getDate() + 7);
}
// Determine payout recipient (next in rotation)
const members = await db.query(
'SELECT * FROM chama_members WHERE chama_id = $1 AND is_active = TRUE ORDER BY rotation_order',
[req.params.chamaId]
);
const recipientIndex = (nextCycleNumber - 1) % members.rows.length;
const payoutRecipient = members.rows[recipientIndex];
// Create the cycle
const cycle = await db.query(
`INSERT INTO contribution_cycles
(chama_id, cycle_number, due_date, payout_recipient_id, total_expected_cents)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[
req.params.chamaId,
nextCycleNumber,
dueDate,
payoutRecipient.id,
chama.rows[0].contribution_amount_cents * members.rows.length,
]
);
// Create contribution records for each member
for (const member of members.rows) {
await db.query(
`INSERT INTO contributions (cycle_id, member_id, amount_due_cents)
VALUES ($1, $2, $3)`,
[cycle.rows[0].id, member.id, chama.rows[0].contribution_amount_cents]
);
}
res.json({
cycle: cycle.rows[0],
payout_recipient: payoutRecipient.name,
member_count: members.rows.length,
});
});
// Collect contribution from a specific member
app.post('/api/contributions/:contributionId/pay', async (req, res) => {
const contribution = await db.query(
`SELECT c.*, cm.phone, cm.name, cm.email, ch.name as chama_name
FROM contributions c
JOIN chama_members cm ON c.member_id = cm.id
JOIN contribution_cycles cc ON c.cycle_id = cc.id
JOIN chamas ch ON cc.chama_id = ch.id
WHERE c.id = $1`,
[req.params.contributionId]
);
if (!contribution.rows[0]) return res.status(404).json({ error: 'Contribution not found' });
const contrib = contribution.rows[0];
const remainingAmount = contrib.amount_due_cents - contrib.amount_paid_cents;
if (remainingAmount <= 0) {
return res.status(400).json({ error: 'Already fully paid' });
}
// Allow partial payments
const paymentAmount = req.body.amount_cents
? Math.min(req.body.amount_cents, remainingAmount)
: remainingAmount;
const chargeRes = await fetch('https://api.paystack.co/charge', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: contrib.email || `${contrib.phone}@chama.local`,
amount: paymentAmount,
currency: 'KES',
mobile_money: {
phone: contrib.phone,
provider: 'mpesa',
},
metadata: {
contribution_id: contrib.id,
member_name: contrib.name,
chama_name: contrib.chama_name,
},
}),
});
const chargeData = await chargeRes.json();
await db.query(
'UPDATE contributions SET paystack_reference = $1 WHERE id = $2',
[chargeData.data.reference, contrib.id]
);
res.json({
status: chargeData.data.status,
message: `Check your phone for the M-Pesa prompt. Amount: KES ${paymentAmount / 100}`,
reference: chargeData.data.reference,
});
});
When the charge.success webhook fires, update the contribution:
async function handleContributionPayment(data) {
const { contribution_id } = data.metadata;
if (!contribution_id) return;
const contribution = await db.query('SELECT * FROM contributions WHERE id = $1', [contribution_id]);
if (!contribution.rows[0]) return;
const newPaidAmount = contribution.rows[0].amount_paid_cents + data.amount;
const isPaidInFull = newPaidAmount >= contribution.rows[0].amount_due_cents;
await db.query(
`UPDATE contributions
SET amount_paid_cents = $1,
status = $2,
paid_at = CASE WHEN $2 = 'paid' THEN NOW() ELSE paid_at END
WHERE id = $3`,
[newPaidAmount, isPaidInFull ? 'paid' : 'partial', contribution_id]
);
// Update the cycle total
const cycle = await db.query(
'SELECT * FROM contribution_cycles WHERE id = $1',
[contribution.rows[0].cycle_id]
);
await db.query(
'UPDATE contribution_cycles SET total_collected_cents = total_collected_cents + $1 WHERE id = $2',
[data.amount, contribution.rows[0].cycle_id]
);
// Record in the chama ledger
await db.query(
`INSERT INTO chama_transactions (chama_id, type, amount_cents, direction, description, reference_id)
VALUES ((SELECT chama_id FROM contribution_cycles WHERE id = $1), 'contribution', $2, 'in', $3, $4)`,
[contribution.rows[0].cycle_id, data.amount, `Contribution from ${data.metadata.member_name}`, contribution_id]
);
// Update treasury balance
await db.query(
`UPDATE chamas SET treasury_balance_cents = treasury_balance_cents + $1
WHERE id = (SELECT chama_id FROM contribution_cycles WHERE id = $2)`,
[data.amount, contribution.rows[0].cycle_id]
);
}
Automatic Reminders
Late contributions are the bane of every chama. Automatic reminders reduce late payments significantly. Send them via SMS because that is what Kenyan chama members check.
// Run daily to check for upcoming and overdue contributions
async function sendContributionReminders() {
const today = new Date();
// Find open cycles with upcoming due dates
const openCycles = await db.query(
`SELECT cc.*, ch.name as chama_name, ch.contribution_amount_cents
FROM contribution_cycles cc
JOIN chamas ch ON cc.chama_id = ch.id
WHERE cc.status = 'open'`
);
for (const cycle of openCycles.rows) {
const dueDate = new Date(cycle.due_date);
const daysUntilDue = Math.ceil((dueDate - today) / (1000 * 60 * 60 * 24));
// Get unpaid members
const unpaid = await db.query(
`SELECT c.*, cm.phone, cm.name
FROM contributions c
JOIN chama_members cm ON c.member_id = cm.id
WHERE c.cycle_id = $1 AND c.status IN ('pending', 'partial')`,
[cycle.id]
);
if (unpaid.rows.length === 0) continue;
// Send reminders at key intervals
if (daysUntilDue === 3 || daysUntilDue === 1 || daysUntilDue === 0) {
for (const member of unpaid.rows) {
const remaining = (member.amount_due_cents - member.amount_paid_cents) / 100;
let message;
if (daysUntilDue > 0) {
message = `${cycle.chama_name}: Your contribution of KES ${remaining} is due in ${daysUntilDue} day(s). Pay via the app or dial *XXX#.`;
} else {
message = `${cycle.chama_name}: Your contribution of KES ${remaining} is due TODAY. Please pay to avoid penalties.`;
}
await sendSMS(member.phone, message);
}
}
// Mark overdue contributions
if (daysUntilDue < 0) {
await db.query(
"UPDATE contributions SET status = 'overdue' WHERE cycle_id = $1 AND status = 'pending'",
[cycle.id]
);
}
}
}
Timing tips:
- Send reminders in the morning (8 AM to 9 AM EAT), not late at night.
- Include the amount and the chama name in every message. Members may belong to multiple chamas.
- After the due date, the tone shifts from reminder to "overdue." Some chamas have penalty rules (e.g., KES 200 fine for late payment). If the chama has penalties, mention them in the overdue notice.
- Include a direct link or instruction for how to pay. "Open the app" or "Dial *384*XXX#" reduces friction.
Merry-Go-Round Payout Rotation
The merry-go-round is the most common chama model. Every cycle, all members contribute. The total pot goes to one member. Next cycle, a different member gets the pot. This rotates until everyone has received, then it starts over.
// Trigger payout when all contributions for a cycle are collected
async function checkAndTriggerPayout(cycleId) {
const cycle = await db.query('SELECT * FROM contribution_cycles WHERE id = $1', [cycleId]);
if (!cycle.rows[0] || cycle.rows[0].status !== 'open') return;
// Check if all members have paid
const unpaid = await db.query(
"SELECT COUNT(*) as count FROM contributions WHERE cycle_id = $1 AND status NOT IN ('paid')",
[cycleId]
);
if (parseInt(unpaid.rows[0].count) > 0) return; // Still waiting for payments
// All paid. Trigger the payout.
const recipient = await db.query(
'SELECT * FROM chama_members WHERE id = $1',
[cycle.rows[0].payout_recipient_id]
);
if (!recipient.rows[0].paystack_recipient_code) {
// Create recipient if not already registered
await createPaystackRecipient(recipient.rows[0]);
}
const payoutAmount = cycle.rows[0].total_collected_cents;
// Create payout record
const payout = await db.query(
`INSERT INTO chama_payouts (cycle_id, recipient_id, amount_cents, status)
VALUES ($1, $2, $3, 'processing') RETURNING id`,
[cycleId, recipient.rows[0].id, payoutAmount]
);
// Initiate the 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: payoutAmount,
recipient: recipient.rows[0].paystack_recipient_code,
reason: `Chama merry-go-round payout - Cycle ${cycle.rows[0].cycle_number}`,
reference: `chama-payout-${payout.rows[0].id}`,
metadata: {
payout_id: payout.rows[0].id,
cycle_id: cycleId,
recipient_name: recipient.rows[0].name,
},
}),
});
const transferData = await transferRes.json();
await db.query(
'UPDATE chama_payouts SET paystack_transfer_code = $1 WHERE id = $2',
[transferData.data.transfer_code, payout.rows[0].id]
);
// Update treasury
await db.query(
`UPDATE chamas SET treasury_balance_cents = treasury_balance_cents - $1
WHERE id = (SELECT chama_id FROM contribution_cycles WHERE id = $2)`,
[payoutAmount, cycleId]
);
// Record in the ledger
await db.query(
`INSERT INTO chama_transactions (chama_id, type, amount_cents, direction, description, reference_id)
VALUES ((SELECT chama_id FROM contribution_cycles WHERE id = $1), 'payout', $2, 'out', $3, $4)`,
[cycleId, payoutAmount, `Merry-go-round payout to ${recipient.rows[0].name}`, payout.rows[0].id]
);
}
async function createPaystackRecipient(member) {
const res = await fetch('https://api.paystack.co/transferrecipient', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'mobile_money',
name: member.name,
account_number: member.phone,
bank_code: 'MPESA',
currency: 'KES',
}),
});
const data = await res.json();
await db.query(
'UPDATE chama_members SET paystack_recipient_code = $1 WHERE id = $2',
[data.data.recipient_code, member.id]
);
}
Some chamas do not wait for all members to pay before disbursing. They pay out on a fixed date regardless, and defaulting members pay penalties later. If your chama works this way, trigger the payout based on the due date, not on contribution completion. Use the amount actually collected, not the expected total.
Member Dashboard and Reporting
Transparency is the feature that makes members trust the digital system over the exercise book. Every member should be able to see:
// GET /api/chamas/:chamaId/dashboard
app.get('/api/chamas/:chamaId/dashboard', async (req, res) => {
const chama = await db.query('SELECT * FROM chamas WHERE id = $1', [req.params.chamaId]);
const members = await db.query(
'SELECT id, name, phone, role, rotation_order FROM chama_members WHERE chama_id = $1 AND is_active = TRUE ORDER BY rotation_order',
[req.params.chamaId]
);
const currentCycle = await db.query(
`SELECT cc.*, cm.name as recipient_name
FROM contribution_cycles cc
LEFT JOIN chama_members cm ON cc.payout_recipient_id = cm.id
WHERE cc.chama_id = $1
ORDER BY cc.cycle_number DESC LIMIT 1`,
[req.params.chamaId]
);
let contributionStatus = [];
if (currentCycle.rows[0]) {
contributionStatus = (await db.query(
`SELECT c.*, cm.name as member_name
FROM contributions c
JOIN chama_members cm ON c.member_id = cm.id
WHERE c.cycle_id = $1
ORDER BY cm.rotation_order`,
[currentCycle.rows[0].id]
)).rows;
}
const recentTransactions = await db.query(
`SELECT * FROM chama_transactions WHERE chama_id = $1 ORDER BY created_at DESC LIMIT 20`,
[req.params.chamaId]
);
// Payout history
const payoutHistory = await db.query(
`SELECT cp.*, cm.name as recipient_name, cc.cycle_number
FROM chama_payouts cp
JOIN chama_members cm ON cp.recipient_id = cm.id
JOIN contribution_cycles cc ON cp.cycle_id = cc.id
WHERE cc.chama_id = $1
ORDER BY cc.cycle_number DESC`,
[req.params.chamaId]
);
res.json({
chama: {
name: chama.rows[0].name,
contribution_amount: chama.rows[0].contribution_amount_cents,
treasury_balance: chama.rows[0].treasury_balance_cents,
member_count: members.rows.length,
},
members: members.rows,
current_cycle: currentCycle.rows[0] ? {
cycle_number: currentCycle.rows[0].cycle_number,
due_date: currentCycle.rows[0].due_date,
recipient: currentCycle.rows[0].recipient_name,
collected: currentCycle.rows[0].total_collected_cents,
expected: currentCycle.rows[0].total_expected_cents,
contributions: contributionStatus.map(c => ({
member: c.member_name,
amount_due: c.amount_due_cents,
amount_paid: c.amount_paid_cents,
status: c.status,
})),
} : null,
payout_history: payoutHistory.rows,
recent_transactions: recentTransactions.rows,
});
});
The dashboard should clearly show:
- Who has paid this cycle and who has not (a green/red/yellow indicator next to each member's name).
- The treasury balance. This must match the sum of all "in" transactions minus all "out" transactions. If it does not, there is a bug.
- Who is next in the payout rotation. Members want to know when their turn is coming.
- Complete payout history. Who got paid, when, and how much.
- Each member's personal contribution history across all cycles.
Edge Cases and Real-World Complications
Chamas are social groups, not just financial systems. The edge cases come from human behavior, not technical limitations.
A member wants to leave. They may want their accumulated contributions back (for investment-type chamas) or simply to stop participating (for merry-go-rounds). Your system needs a "leave chama" flow that handles the financial settlement and removes them from the rotation. For a merry-go-round, if a member leaves before their payout turn, the remaining members need to decide what happens.
A new member joins mid-cycle. Do they start contributing immediately? Do they go to the end of the payout rotation? Most chamas put new members at the end of the rotation and require them to start contributing in the next cycle.
The payout recipient is unreachable. Their phone is off, their M-Pesa is not registered, or the transfer fails. Hold the funds in the treasury and retry. Set a deadline (e.g., 7 days) after which the chama decides what to do with the unclaimed payout.
Penalties for late payment. Many chamas charge KES 100 to 500 for paying after the due date. Your system should track penalties as separate charges. Add a penalty field to the contributions table and include it in the next collection cycle.
Emergency loans from the treasury. Some chamas lend money from the treasury to members at an interest rate. This is a separate feature (essentially a micro-lending system) that requires its own data model and repayment tracking. Build contributions first, add lending later.
WhatsApp integration. Many chama members do not want to download an app or visit a website. They live in WhatsApp. Consider building a WhatsApp bot that sends payment reminders, shares the contribution status, and provides a payment link. See WhatsApp Bot That Collects Payment via Paystack for the technical approach.
Deployment Notes
Trust and adoption. The biggest challenge is not technical. It is convincing the chama treasurer (who currently controls the money) to use a system that makes everything transparent. Position the product as a tool that protects the treasurer from accusations, not one that takes away their power.
Paystack balance management. Contributions come into your Paystack balance. Payouts go out of it. If your settlement cycle is longer than your payout cycle, you need working capital to cover the gap. Alternatively, accumulate contributions for the full cycle before disbursing.
Data privacy. Members' phone numbers, names, and financial data are personal information under Kenya's Data Protection Act. Store data securely. Do not expose member financial details to other members beyond what is necessary for transparency (e.g., show that John paid, but do not show John's phone number to other members).
SMS costs. Sending SMS reminders to 20 members three times per cycle adds up. Budget for SMS costs. Use Africa's Talking or a similar provider with competitive rates for Kenyan SMS.
Mobile-first design. Most chama members will access the system on their phones. Build a responsive web app or a PWA. Keep it simple. Large buttons, clear text, minimal data usage. The member who struggles with technology should be able to see their contribution status in one tap.
For the full Paystack Kenya integration context, see Paystack in Kenya: M-Pesa, Pesalink, and the Daraja Question. For Transfers, see Paystack Transfers to M-Pesa Wallets Explained.
The McTaba 26-week bootcamp (KES 120,000) includes building a real fintech product as a capstone project. A chama tracker is exactly the kind of product that works as a portfolio piece and solves a real problem.
Key Takeaways
- ✓A chama tracker solves a real problem. Most Kenyan chamas track contributions in WhatsApp groups and exercise books. A digital system with integrated payments reduces arguments over who paid and who did not.
- ✓Collect contributions through Paystack using the Charge API with M-Pesa. Each month, trigger an STK push to each member. Track who has paid and who has not.
- ✓The merry-go-round (rotating payout) is the core feature for many chamas. Build a rotation schedule and use Paystack Transfers to disburse the pot to the designated member each cycle.
- ✓Automatic SMS reminders before the contribution deadline reduce late payments. Send reminders 3 days before, 1 day before, and on the due date.
- ✓Financial transparency is the killer feature. Every member should be able to see total contributions, their own payment history, the treasury balance, and upcoming payouts.
- ✓Handle partial payments. Some members send part of their contribution when they cannot afford the full amount. Your system should track partial payments and show the remaining balance.
Frequently Asked Questions
- Can I use Paystack for both collecting contributions and disbursing payouts?
- Yes. Use the Charge API to collect M-Pesa contributions from members, and the Transfers API to disburse the merry-go-round payout to the designated recipient. The money flows into your Paystack balance (contributions) and out of it (payouts). You need to enable Transfers on your Paystack account, which requires approval from Paystack.
- How do I handle a member who pays only part of their contribution?
- Track amount_due and amount_paid separately. When a partial payment comes through the webhook, add it to amount_paid and set the status to "partial." The member can make another payment later for the remaining balance. Show the outstanding amount clearly on their dashboard so they know what they still owe.
- What if not all members have paid by the payout date?
- This depends on the chama rules. Some chamas only pay out what has been collected, even if some members have not contributed. Others wait until all members pay. Build a configuration option on the chama settings. If the chama chooses to pay out on a fixed date, use the total_collected_cents as the payout amount, not total_expected_cents.
- Can members belong to multiple chamas?
- Yes. The data model supports this. A person can have multiple chama_members records, one per chama. Each chama has its own contribution cycles, rotation order, and treasury. The member sees all their chamas on their dashboard and manages each one separately.
- Is there a minimum amount for Paystack Transfers in Kenya?
- Paystack has minimum transfer amounts that depend on the transfer type and destination. Check Paystack documentation for current minimums. For most chama payouts, the amounts will be well above any minimum since they represent the combined contributions of all members for the cycle.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon