Build a Group Contribution App
Build a group contribution app by creating contribution cycles (monthly), sending each member a Paystack payment link for their fixed contribution, tracking payments per member per cycle, and disbursing the pooled amount to the designated member when all (or a quorum) have contributed. Use webhooks to track each member's payment as it arrives.
Group Setup and Contribution Cycles
Tables: groups (name, contribution_amount, cycle_frequency, payout_order[], created_by), members (group_id, user_id, name, email, phone, bank_account, paystack_recipient_code, payout_position), cycles (group_id, cycle_number, month, year, recipient_member_id, status, total_collected), contributions (cycle_id, member_id, amount, paystack_ref, paid_at).
async function startNewCycle(groupId) {
var group = await db.groups.findById(groupId);
var lastCycle = await db.cycles.findLatest(groupId);
var cycleNumber = (lastCycle?.cycle_number || 0) + 1;
var recipientIndex = (cycleNumber - 1) % group.payout_order.length;
var recipientMemberId = group.payout_order[recipientIndex];
var now = new Date();
var cycle = await db.cycles.create({
group_id: groupId,
cycle_number: cycleNumber,
month: now.getMonth() + 1,
year: now.getFullYear(),
recipient_member_id: recipientMemberId,
status: 'open',
total_collected: 0,
});
// Send payment links to all members
var members = await db.members.findByGroup(groupId);
for (var member of members) {
var reference = 'CHAMA-' + cycle.id + '-' + member.id + '-' + Date.now();
await db.contributions.create({ cycle_id: cycle.id, member_id: member.id, paystack_ref: reference, status: 'pending' });
var res = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
email: member.email,
amount: group.contribution_amount,
currency: 'NGN',
reference,
metadata: { cycle_id: cycle.id, member_id: member.id, group_id: groupId },
}),
});
var link = (await res.json()).data.authorization_url;
await sendContributionRequest(member.phone, link, group.name, group.contribution_amount / 100);
}
return cycle;
}
Contribution Tracking and Cycle Payout
if (event.event === 'charge.success') {
var meta = event.data.metadata;
var contribution = await db.contributions.findByRef(event.data.reference);
await db.contributions.update(contribution.id, { status: 'paid', paid_at: new Date() });
await db.cycles.increment(meta.cycle_id, 'total_collected', event.data.amount);
// Check if cycle is fully funded or deadline passed
var cycle = await db.cycles.findById(meta.cycle_id);
var group = await db.groups.findById(meta.group_id);
var members = await db.members.findByGroup(meta.group_id);
var paidCount = await db.contributions.countPaid(meta.cycle_id);
if (paidCount >= members.length) {
await disburseCycle(cycle, group);
}
}
async function disburseCycle(cycle, group) {
var recipient = await db.members.findById(cycle.recipient_member_id);
// Ensure recipient has a Paystack transfer recipient code
if (!recipient.paystack_recipient_code) {
var 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: 'nuban', name: recipient.name, account_number: recipient.account_number, bank_code: recipient.bank_code, currency: 'NGN' }),
});
var code = (await res.json()).data.recipient_code;
await db.members.update(recipient.id, { paystack_recipient_code: code });
recipient.paystack_recipient_code = code;
}
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: cycle.total_collected,
recipient: recipient.paystack_recipient_code,
reason: group.name + ' — Cycle ' + cycle.cycle_number + ' payout',
}),
});
await db.cycles.update(cycle.id, { status: 'disbursed' });
await notifyAllMembers(group.id, recipient.name, cycle.total_collected);
}
Learn More
See build a bill splitting app for the simpler per-person payment link pattern without the rotation payout structure.
Key Takeaways
- ✓Generate a unique Paystack payment link per member per cycle — track payments individually.
- ✓Allow partial cycle funding: proceed if 80% of members have contributed by the deadline.
- ✓Record payout rotation order at group creation time to avoid disputes.
- ✓Disburse pooled funds via Paystack Transfers to the cycle's designated recipient.
- ✓Maintain an immutable ledger of all contributions and disbursements for member transparency.
Frequently Asked Questions
- What happens if a member fails to contribute in a cycle?
- Define a grace period (e.g., 3 days after the contribution deadline). If a member has not paid by the deadline, send a final reminder. After the grace period, mark their contribution as "defaulted." The group admin decides whether to proceed with the payout using the collected amount or delay until the defaulter pays. Track defaulters and implement a penalty policy (extra contribution next cycle or removal from the group).
- Is this system compliant with SACCO regulations in Kenya?
- Informal chama groups (friends, family) do not require SACCO registration. Formal SACCOs must register with the SACCO Societies Regulatory Authority (SASRA) in Kenya and comply with SACCO Societies Act requirements. This app is suitable for informal chamas but should not be used for formal SACCO operations without proper legal structuring. Consult a financial regulatory lawyer for formal SACCO operations.
- Can members join mid-cycle?
- It is cleaner to allow new members only at the start of a new cycle. Mid-cycle joins require prorating contributions and adjusting the payout pool, which complicates accounting. Set a clear rule at group creation: no new members mid-cycle. New members who join before the current cycle starts are added to the payout rotation for the next 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