Bonaventure OgetoBy Bonaventure Ogeto|

Building a Church or Mosque Contributions Platform

Build a contributions platform by registering members with phone numbers, defining fund categories (tithe, offering, zakat, building fund, missions), and collecting contributions via Paystack M-Pesa STK push. Use Paystack metadata to tag each contribution with the member ID, fund, and purpose. Support recurring giving by initiating monthly STK push charges on a scheduled date. Generate contribution statements that members can use for personal records or tax purposes.

Why Kenyan Churches and Mosques Need Digital Giving

Walk into a Kenyan church on Sunday and you will see the offering basket go around. Notes and coins are collected, counted by two or three people after service, recorded in a book, and deposited in the church bank account the next day. The total is announced to the congregation the following Sunday.

This process works, but it has problems:

  • Cash handling risk. Large amounts of cash sitting in the church office between Sunday and Monday's bank deposit is a security concern.
  • No individual tracking. The church has no record of who gave what, unless members fill out offering envelopes with their names. Most do not.
  • Tithe versus offering confusion. A member gives KES 10,000. Was it tithe? Offering? Building fund? Without a system, the church cannot allocate it correctly.
  • No contribution statements. At year end, members who want a record of their giving have nothing to show. No statement, no summary, no receipt.
  • Missed giving. When a member is travelling, sick, or simply forgets their wallet, they give nothing that Sunday. There is no way to give remotely.

A digital contributions platform solves all of these. Members give via M-Pesa from their phone, whether they are in the pew or at home. Every contribution is tracked by member, fund, and date. Statements are generated automatically. Cash handling is minimized.

The same applies to mosques with their own contribution structures: zakat (obligatory almsgiving), sadaqah (voluntary charity), and project-specific funds for madrasa construction, mosque renovation, or community programs.

The technical foundation is Paystack, which handles M-Pesa and card payments through a single API. The institution-specific logic (fund allocation, member management, statements) is what you build on top.

Member Registration

Members register by phone number. This is both their identity and their payment method. Some institutions assign member numbers. Others use phone numbers directly. Support both.

const createMembersTable = `
  CREATE TABLE members (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    member_number VARCHAR(20) UNIQUE,        -- Optional: church/mosque member ID
    phone VARCHAR(15) UNIQUE NOT NULL,
    first_name VARCHAR(100) NOT NULL,
    last_name VARCHAR(100) NOT NULL,
    email VARCHAR(200),
    family_group VARCHAR(100),               -- 'Kamau Family', cell group, etc.
    fellowship VARCHAR(100),                 -- 'Youth', 'Women', 'Men', etc.
    join_date DATE DEFAULT CURRENT_DATE,
    status VARCHAR(20) DEFAULT 'active',     -- 'active', 'inactive', 'transferred'
    anonymous_preference BOOLEAN DEFAULT false, -- Prefers anonymous giving
    created_at TIMESTAMP DEFAULT NOW()
  );
`;

const createFundsTable = `
  CREATE TABLE funds (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(100) NOT NULL,
    type VARCHAR(50) NOT NULL,               -- 'tithe', 'offering', 'zakat', 'sadaqah', 'building', 'missions', 'project'
    description TEXT,
    target_amount INT,                       -- For campaigns/projects (in cents)
    current_amount INT DEFAULT 0,
    active BOOLEAN DEFAULT true,
    start_date DATE,
    end_date DATE,                           -- For time-limited campaigns
    created_at TIMESTAMP DEFAULT NOW()
  );
`;

const createContributionsTable = `
  CREATE TABLE contributions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    reference VARCHAR(100) UNIQUE NOT NULL,
    member_id UUID REFERENCES members(id),   -- NULL for anonymous
    fund_id UUID REFERENCES funds(id) NOT NULL,
    amount INT NOT NULL,                     -- In cents
    payment_method VARCHAR(20) NOT NULL,     -- 'mpesa', 'card', 'cash', 'pesalink'
    is_anonymous BOOLEAN DEFAULT false,
    is_recurring BOOLEAN DEFAULT false,
    recurring_pledge_id UUID REFERENCES recurring_pledges(id),
    status VARCHAR(20) DEFAULT 'pending',    -- 'pending', 'completed', 'failed'
    completed_at TIMESTAMP,
    service_date DATE,                       -- Which service/day this was for
    notes TEXT,
    created_at TIMESTAMP DEFAULT NOW()
  );
`;

const createRecurringPledgesTable = `
  CREATE TABLE recurring_pledges (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    member_id UUID REFERENCES members(id),
    fund_id UUID REFERENCES funds(id),
    amount INT NOT NULL,
    frequency VARCHAR(20) NOT NULL,          -- 'weekly', 'monthly'
    charge_day INT,                          -- Day of week (1-7) or month (1-28)
    active BOOLEAN DEFAULT true,
    next_charge_date DATE,
    created_at TIMESTAMP DEFAULT NOW()
  );
`;

A few design decisions:

  • Member number is optional. Some churches assign numbers. Others do not. The phone number is always required and serves as the primary identifier for payments.
  • Fellowship/family group. Useful for reporting. "The youth fellowship contributed KES 150,000 this quarter" is a report the youth leader wants to see.
  • Anonymous preference. If a member sets this flag, their contributions are recorded for financial tracking but their name does not appear in any public reports or announcements.
  • Service date. This ties the contribution to a specific service or day. Important for churches that want to track giving per service ("Sunday morning service raised KES 250,000").

Collecting Contributions via M-Pesa and Card

The contribution flow is simple from the member's perspective: choose a fund, enter an amount, pay via M-Pesa. The complexity is in the metadata and tracking.

// Collect a contribution
app.post('/api/contributions', async (req, res) => {
  const { memberId, fundId, amount, isAnonymous, serviceDate } = req.body;

  const fund = await db.query('SELECT * FROM funds WHERE id = $1', [fundId]);

  let member = null;
  let phone = req.body.phone; // For anonymous or non-member giving

  if (memberId && !isAnonymous) {
    member = await db.query('SELECT * FROM members WHERE id = $1', [memberId]);
    phone = member[0].phone;
  }

  const reference = `GIVE-${fund[0].type.toUpperCase().slice(0, 3)}-${Date.now()}`;

  const chargePayload = {
    email: member ? (member[0].email || `${member[0].phone}@church.portal`) : `${phone}@guest.portal`,
    amount,
    currency: 'KES',
    reference,
    mobile_money: {
      phone,
      provider: 'mpesa',
    },
    metadata: {
      member_id: isAnonymous ? null : (memberId || null),
      fund_id: fundId,
      fund_name: fund[0].name,
      fund_type: fund[0].type,
      is_anonymous: isAnonymous || false,
      service_date: serviceDate || new Date().toISOString().split('T')[0],
      custom_fields: [
        { display_name: 'Fund', variable_name: 'fund', value: fund[0].name },
        { display_name: 'Type', variable_name: 'type', value: isAnonymous ? 'Anonymous' : 'Member' },
      ],
    },
  };

  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('contributions', {
    reference,
    member_id: isAnonymous ? null : (memberId || null),
    fund_id: fundId,
    amount,
    payment_method: 'mpesa',
    is_anonymous: isAnonymous || false,
    status: 'pending',
    service_date: serviceDate || new Date().toISOString().split('T')[0],
  });

  res.json({
    status: 'pending',
    reference,
    fund: fund[0].name,
    amount,
    message: 'Check your phone for the M-Pesa prompt',
  });
});

The member-facing interface can be a simple mobile web page. During service, the pastor or imam announces: "To give your tithe via M-Pesa, visit give.churchname.com on your phone." The page shows the available funds, the member enters their amount and phone number, and the STK push goes out.

For members who prefer giving during the offering time, the flow takes about 30 seconds: open the page, tap the fund, enter the amount, confirm on their phone. It is faster than fumbling for cash in a wallet.

For card payments (useful for diaspora members or those who prefer cards), use Paystack's standard checkout flow instead of the Charge API. The same metadata structure applies. The webhook handler processes both M-Pesa and card payments identically.

Recurring Giving: Monthly Pledges

Recurring giving is where the real financial stability comes from. A church with 200 members pledging KES 2,000 monthly has a predictable KES 400,000 per month in tithe alone, before any one-time offerings. This predictability changes how the church plans its budget.

// Create a recurring pledge
app.post('/api/pledges', async (req, res) => {
  const { memberId, fundId, amount, frequency, chargeDay } = req.body;

  const member = await db.query('SELECT * FROM members WHERE id = $1', [memberId]);
  const fund = await db.query('SELECT * FROM funds WHERE id = $1', [fundId]);

  // Calculate next charge date
  const nextChargeDate = calculateNextChargeDate(frequency, chargeDay);

  const pledge = await db.insert('recurring_pledges', {
    member_id: memberId,
    fund_id: fundId,
    amount,
    frequency,
    charge_day: chargeDay,
    active: true,
    next_charge_date: nextChargeDate,
  });

  const amountKES = (amount / 100).toLocaleString();
  await sendSMS(
    member[0].phone,
    `Your monthly pledge of KES ${amountKES} to ${fund[0].name} has been set up. You will receive an M-Pesa prompt on the ${chargeDay}th of each month. Reply STOP to cancel.`
  );

  res.json({
    pledgeId: pledge.id,
    fund: fund[0].name,
    amount,
    frequency,
    nextChargeDate,
  });
});

// Scheduled job: process recurring pledges
async function processRecurringPledges() {
  const today = new Date().toISOString().split('T')[0];

  const duePledges = await db.query(
    `SELECT rp.*, m.phone, m.email, m.first_name, f.name as fund_name, f.type as fund_type
     FROM recurring_pledges rp
     JOIN members m ON rp.member_id = m.id
     JOIN funds f ON rp.fund_id = f.id
     WHERE rp.active = true
       AND rp.next_charge_date = $1`,
    [today]
  );

  for (const pledge of duePledges) {
    // Notify the member before charging
    const amountKES = (pledge.amount / 100).toLocaleString();
    await sendSMS(
      pledge.phone,
      `Your monthly ${pledge.fund_name} pledge of KES ${amountKES} will be charged via M-Pesa shortly. Ensure your M-Pesa has sufficient balance.`
    );

    // Short delay to let the SMS arrive
    await delay(5000);

    // Initiate the charge
    const reference = `PLEDGE-${pledge.id.slice(0, 8)}-${Date.now()}`;

    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: pledge.email || `${pledge.phone}@church.portal`,
        amount: pledge.amount,
        currency: 'KES',
        reference,
        mobile_money: {
          phone: pledge.phone,
          provider: 'mpesa',
        },
        metadata: {
          member_id: pledge.member_id,
          fund_id: pledge.fund_id,
          fund_name: pledge.fund_name,
          fund_type: pledge.fund_type,
          is_recurring: true,
          recurring_pledge_id: pledge.id,
        },
      }),
    });

    await db.insert('contributions', {
      reference,
      member_id: pledge.member_id,
      fund_id: pledge.fund_id,
      amount: pledge.amount,
      payment_method: 'mpesa',
      is_recurring: true,
      recurring_pledge_id: pledge.id,
      status: 'pending',
      service_date: today,
    });

    // Update next charge date
    const nextDate = calculateNextChargeDate(pledge.frequency, pledge.charge_day);
    await db.query(
      'UPDATE recurring_pledges SET next_charge_date = $1 WHERE id = $2',
      [nextDate, pledge.id]
    );
  }
}

function calculateNextChargeDate(frequency, chargeDay) {
  const now = new Date();
  if (frequency === 'monthly') {
    let month = now.getMonth() + 1;
    let year = now.getFullYear();
    if (now.getDate() >= chargeDay) {
      month += 1;
      if (month > 11) { month = 0; year += 1; }
    }
    return new Date(year, month, Math.min(chargeDay, 28)).toISOString().split('T')[0];
  }
  // Weekly logic would go here
  return now.toISOString().split('T')[0];
}

Handle failures gracefully. If the member declines the STK push or their M-Pesa balance is insufficient, send a follow-up SMS with a payment link: "Your monthly tithe of KES 5,000 did not go through. Tap here to give now: [link]." Do not retry automatically without notice. People do not like surprise charges from a church.

Also provide a way to cancel or pause pledges. A member going through financial difficulty should be able to pause their pledge without calling anyone. A simple SMS reply or a toggle in the giving portal handles this.

Campaign and Project Fundraising

Building fund. Renovation project. Mission trip. Community feeding program. These are time-limited fundraising campaigns with a target amount. The congregation wants to see progress toward the goal.

// Create a fundraising campaign
app.post('/api/campaigns', async (req, res) => {
  const { name, description, targetAmount, startDate, endDate } = req.body;

  const fund = await db.insert('funds', {
    name,
    type: 'project',
    description,
    target_amount: targetAmount,
    current_amount: 0,
    active: true,
    start_date: startDate,
    end_date: endDate,
  });

  res.json({ fundId: fund.id, name, targetAmount });
});

// Get campaign progress
app.get('/api/campaigns/:fundId/progress', async (req, res) => {
  const { fundId } = req.params;

  const fund = await db.query('SELECT * FROM funds WHERE id = $1', [fundId]);

  const contributions = await db.query(
    `SELECT
       COUNT(*) as total_contributors,
       SUM(amount) as total_raised,
       COUNT(DISTINCT member_id) as unique_givers
     FROM contributions
     WHERE fund_id = $1 AND status = 'completed'`,
    [fundId]
  );

  const recentContributions = await db.query(
    `SELECT c.amount, c.is_anonymous, c.completed_at,
            m.first_name, m.last_name
     FROM contributions c
     LEFT JOIN members m ON c.member_id = m.id
     WHERE c.fund_id = $1 AND c.status = 'completed'
     ORDER BY c.completed_at DESC
     LIMIT 10`,
    [fundId]
  );

  const percentComplete = fund[0].target_amount > 0
    ? Math.round((contributions[0].total_raised / fund[0].target_amount) * 100)
    : 0;

  res.json({
    campaign: fund[0].name,
    target: fund[0].target_amount,
    raised: contributions[0].total_raised || 0,
    percentComplete,
    totalContributors: contributions[0].total_contributors,
    uniqueGivers: contributions[0].unique_givers,
    daysRemaining: fund[0].end_date
      ? Math.max(0, Math.ceil((new Date(fund[0].end_date) - new Date()) / (1000 * 60 * 60 * 24)))
      : null,
    recentContributions: recentContributions.map(c => ({
      amount: c.amount,
      name: c.is_anonymous ? 'Anonymous' : `${c.first_name} ${c.last_name}`,
      date: c.completed_at,
    })),
  });
});

Display campaign progress on the giving page and during announcements. "We have raised KES 2.3 million of our KES 5 million building fund target. 46% complete. 187 members have contributed." This transparency motivates giving. People want to contribute to something that is making progress.

For the recent contributions list, respect the anonymous flag. Show "Anonymous - KES 50,000" instead of the member's name if they opted for anonymous giving.

Anonymous Giving

Some members want to give without others knowing the amount. This is a deeply held preference in many congregations. Jesus said "do not let your left hand know what your right hand is doing." Your system needs to respect this.

Anonymous giving does not mean untracked giving. The institution still needs to know the total amount for financial reporting. What "anonymous" means is:

  • The contribution is not linked to a member record (or the link is hidden from reports).
  • The member's name does not appear in contribution announcements or public reports.
  • The member does not receive a personalized contribution statement (or receives one only on explicit request).
  • The financial team sees the total amount but not the contributor's identity in standard reports.
// Anonymous contribution: member_id is set to NULL in the contributions table
// The payment is still processed through Paystack with the member's phone number
// (Paystack needs this for the charge), but the contribution record in your system
// does not link to the member

app.post('/api/contributions/anonymous', async (req, res) => {
  const { phone, fundId, amount } = req.body;

  const fund = await db.query('SELECT * FROM funds WHERE id = $1', [fundId]);
  const reference = `ANON-${fund[0].type.toUpperCase().slice(0, 3)}-${Date.now()}`;

  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: `${phone}@anonymous.portal`,
      amount,
      currency: 'KES',
      reference,
      mobile_money: { phone, provider: 'mpesa' },
      metadata: {
        fund_id: fundId,
        fund_name: fund[0].name,
        is_anonymous: true,
        custom_fields: [
          { display_name: 'Fund', variable_name: 'fund', value: fund[0].name },
          { display_name: 'Type', variable_name: 'type', value: 'Anonymous' },
        ],
      },
    }),
  });

  await db.insert('contributions', {
    reference,
    member_id: null,  // Anonymous: no member link
    fund_id: fundId,
    amount,
    payment_method: 'mpesa',
    is_anonymous: true,
    status: 'pending',
    service_date: new Date().toISOString().split('T')[0],
  });

  res.json({ status: 'pending', reference, fund: fund[0].name });
});

Note: Paystack processes the payment using the phone number, so the transaction is visible in the Paystack dashboard with the phone number attached. True anonymity from the payment processor is not possible. But in your institution's system, the contribution is not linked to a member, which is what matters for the internal reporting and social privacy that the member cares about.

Contribution Statements for Members

A contribution statement summarizes everything a member has given over a period. It is the digital equivalent of the offering envelope record that a few dedicated churches maintain on paper.

// Generate a contribution statement for a member
app.get('/api/members/:memberId/statement', async (req, res) => {
  const { memberId } = req.params;
  const { year } = req.query;
  const statementYear = year || new Date().getFullYear();

  const member = await db.query('SELECT * FROM members WHERE id = $1', [memberId]);

  const contributions = await db.query(
    `SELECT c.amount, c.completed_at, c.service_date, c.reference,
            f.name as fund_name, f.type as fund_type
     FROM contributions c
     JOIN funds f ON c.fund_id = f.id
     WHERE c.member_id = $1
       AND c.status = 'completed'
       AND EXTRACT(YEAR FROM c.completed_at) = $2
     ORDER BY c.completed_at ASC`,
    [memberId, statementYear]
  );

  // Summarize by fund
  const fundSummary = {};
  for (const c of contributions) {
    if (!fundSummary[c.fund_name]) {
      fundSummary[c.fund_name] = { total: 0, count: 0 };
    }
    fundSummary[c.fund_name].total += c.amount;
    fundSummary[c.fund_name].count += 1;
  }

  // Summarize by month
  const monthlySummary = {};
  for (const c of contributions) {
    const month = new Date(c.completed_at).toLocaleString('en-KE', { month: 'long' });
    if (!monthlySummary[month]) monthlySummary[month] = 0;
    monthlySummary[month] += c.amount;
  }

  const totalGiving = contributions.reduce((sum, c) => sum + c.amount, 0);

  res.json({
    member: {
      name: `${member[0].first_name} ${member[0].last_name}`,
      memberNumber: member[0].member_number,
    },
    year: statementYear,
    totalGiving,
    totalContributions: contributions.length,
    fundSummary,
    monthlySummary,
    contributions: contributions.map(c => ({
      date: c.completed_at,
      fund: c.fund_name,
      amount: c.amount,
      reference: c.reference,
    })),
  });
});

The statement should be available as a downloadable PDF from the member portal. At the end of each year, send every active member their annual statement via SMS with a download link.

The statement includes:

  • Member name and member number
  • Period (typically January to December)
  • Total contributions across all funds
  • Breakdown by fund (tithe: KES X, offering: KES Y, building fund: KES Z)
  • Monthly totals
  • Individual transaction list with dates and references

Some members use these statements for personal budgeting. Others may need them for tax purposes if charitable giving becomes deductible. Either way, providing the statement builds trust and demonstrates that the institution takes financial accountability seriously.

Multiple Fund Allocation

A church or mosque typically manages several funds simultaneously. The financial team needs to know how much is in each fund, how much has been raised versus the budget, and where the money is going.

// Fund allocation report
app.get('/api/reports/fund-allocation', async (req, res) => {
  const { startDate, endDate } = req.query;

  const fundReport = await db.query(
    `SELECT
       f.name,
       f.type,
       f.target_amount,
       COALESCE(SUM(c.amount), 0) as total_received,
       COUNT(c.id) as transaction_count,
       COUNT(DISTINCT c.member_id) as unique_contributors
     FROM funds f
     LEFT JOIN contributions c ON f.id = c.fund_id
       AND c.status = 'completed'
       AND c.completed_at BETWEEN $1 AND $2
     WHERE f.active = true
     GROUP BY f.id
     ORDER BY total_received DESC`,
    [startDate, endDate]
  );

  const totalAllFunds = fundReport.reduce((sum, f) => sum + parseInt(f.total_received), 0);

  res.json({
    period: { startDate, endDate },
    totalAllFunds,
    funds: fundReport.map(f => ({
      name: f.name,
      type: f.type,
      target: f.target_amount,
      received: parseInt(f.total_received),
      percentOfTarget: f.target_amount
        ? Math.round((parseInt(f.total_received) / f.target_amount) * 100)
        : null,
      percentOfTotal: Math.round((parseInt(f.total_received) / totalAllFunds) * 100),
      transactions: parseInt(f.transaction_count),
      uniqueContributors: parseInt(f.unique_contributors),
    })),
  });
});

Typical fund categories for a Kenyan church:

  • Tithe. 10% of income, given regularly. This is the core operational fund for most churches.
  • Offering. General, voluntary giving above tithe. Often collected during service.
  • Building Fund. Capital projects like church construction or renovation.
  • Missions. Supporting missionaries, outreach programs, and community service.
  • Benevolence. Helping members in need (medical bills, funeral expenses).
  • Youth/Women/Men fellowship. Specific group activities and programs.

For mosques:

  • Zakat. Obligatory almsgiving, calculated as 2.5% of qualifying wealth.
  • Sadaqah. Voluntary charity, any amount.
  • Waqf. Endowment for the mosque or community property.
  • Madrasa. Islamic education programs.
  • Community Projects. Specific initiatives like water wells, education sponsorships.

Each fund operates independently. The tithe fund covers operational expenses. The building fund is restricted to construction costs. Your reporting should make it clear that money given to one fund is not being spent from another. This segregation is what builds financial trust with the congregation.

Annual Financial Reports

Church boards and mosque committees need annual financial reports. These reports show total income by category, month-by-month trends, member participation rates, and campaign progress. Generating them manually from M-Pesa statements and paper records takes weeks. Generating them from your contribution database takes seconds.

// Annual financial summary
app.get('/api/reports/annual/:year', async (req, res) => {
  const { year } = req.params;

  // Monthly breakdown
  const monthlyData = await db.query(
    `SELECT
       EXTRACT(MONTH FROM completed_at) as month,
       SUM(amount) as total,
       COUNT(id) as transactions,
       COUNT(DISTINCT member_id) as unique_givers
     FROM contributions
     WHERE status = 'completed'
       AND EXTRACT(YEAR FROM completed_at) = $1
     GROUP BY EXTRACT(MONTH FROM completed_at)
     ORDER BY month`,
    [year]
  );

  // Top giving categories
  const categoryData = await db.query(
    `SELECT f.name, f.type, SUM(c.amount) as total
     FROM contributions c
     JOIN funds f ON c.fund_id = f.id
     WHERE c.status = 'completed'
       AND EXTRACT(YEAR FROM c.completed_at) = $1
     GROUP BY f.id
     ORDER BY total DESC`,
    [year]
  );

  // Member participation
  const participation = await db.query(
    `SELECT
       (SELECT COUNT(*) FROM members WHERE status = 'active') as total_members,
       COUNT(DISTINCT member_id) as contributing_members
     FROM contributions
     WHERE status = 'completed'
       AND EXTRACT(YEAR FROM completed_at) = $1
       AND member_id IS NOT NULL`,
    [year]
  );

  // Recurring vs one-time
  const recurringData = await db.query(
    `SELECT
       is_recurring,
       SUM(amount) as total,
       COUNT(id) as transactions
     FROM contributions
     WHERE status = 'completed'
       AND EXTRACT(YEAR FROM completed_at) = $1
     GROUP BY is_recurring`,
    [year]
  );

  const grandTotal = monthlyData.reduce((sum, m) => sum + parseInt(m.total), 0);

  res.json({
    year: parseInt(year),
    grandTotal,
    monthlyBreakdown: monthlyData,
    categoryBreakdown: categoryData,
    participation: {
      totalMembers: parseInt(participation[0].total_members),
      contributingMembers: parseInt(participation[0].contributing_members),
      participationRate: Math.round(
        (parseInt(participation[0].contributing_members) / parseInt(participation[0].total_members)) * 100
      ),
    },
    recurringVsOneTime: recurringData,
  });
});

The annual report answers the questions the board asks every year: "How much did we raise?" "How does it compare to last year?" "What percentage of members are giving?" "Are our recurring pledges growing or shrinking?" "Did we meet our building fund target?"

Present this as a PDF report that the treasurer can share at the annual general meeting. Include charts if your PDF generation library supports them. The report should be detailed enough for accountability but clear enough that a non-technical board member can understand it.

Learn to Build Giving Platforms

A contributions platform for a religious institution is a meaningful project that serves a real community need. The technical skills (payment integration, database design, recurring jobs, reporting) are the same skills used in every production payment system.

The McTaba M-Pesa Integration course (KES 9,999) teaches the payment collection patterns at the heart of this platform: STK push, webhooks, metadata design, and transaction tracking. 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 engineering toolkit: databases, APIs, scheduled jobs, reporting, deployment, and building production systems that communities depend on.

Key Takeaways

  • M-Pesa is the primary giving channel for Kenyan congregations. Most members will contribute via STK push rather than card. Build the entire experience around mobile money.
  • Fund allocation is essential. A single "donations" bucket is not enough. Churches need tithe, offering, building fund, missions, and project-specific categories. Mosques need zakat, sadaqah, and specific project funds. Each contribution must be tagged to a specific fund.
  • Recurring giving (monthly pledges) is the most valuable feature for institutions. A member who pledges KES 5,000 monthly and gets an automatic STK push is more consistent than one who gives ad hoc.
  • Anonymous giving must be supported. Some members prefer to give without their name attached. Your system should allow contributions that are tracked for financial reporting but not linked to a specific member.
  • Contribution statements replace the manual paper records that most churches and mosques currently maintain. Members get an annual or on-demand statement showing all their giving by category.
  • Campaign fundraising (building fund, community project) tracks progress toward a goal. Show the congregation how much has been raised and how much remains. Transparency drives more giving.
  • Annual financial reports generated from contribution data give church elders and mosque committees the accountability reporting they need without manual spreadsheet work.

Frequently Asked Questions

Can members give tithes and offerings via M-Pesa?
Yes. The platform uses the Paystack Charge API with mobile_money provider set to "mpesa." When a member initiates a contribution, an STK push is sent to their phone. They enter their M-Pesa PIN and the contribution is recorded against their member profile and the selected fund. An SMS confirmation follows immediately.
How does recurring giving work with M-Pesa?
The member sets up a monthly pledge specifying the amount, fund, and charge day. On the scheduled day each month, the system sends an SMS notification followed by an M-Pesa STK push. The member enters their PIN to authorize the payment. If the charge fails (insufficient balance or decline), a follow-up SMS with a manual payment link is sent. The system does not retry without notification.
Can people give anonymously?
Yes. Anonymous contributions are processed through Paystack using the giver's phone number (required for M-Pesa) but are not linked to a member record in the institution's database. The contribution counts toward the fund total and appears in financial reports, but the giver's name is not associated with it in any member-facing or public reports.
How are contribution statements generated?
The system generates contribution statements from the database. A statement shows all contributions by a member over a period (usually a year), broken down by fund and by month, with a grand total. Statements are available as downloadable PDFs from the member portal. An annual statement is sent to all active members at year end via SMS with a download link.
Can the platform handle both church and mosque contribution models?
Yes. The fund system is flexible. For churches, you create funds for tithe, offering, building fund, and missions. For mosques, you create funds for zakat, sadaqah, waqf, and madrasa. The underlying system is the same: member registration, contribution collection, fund allocation, and reporting. The fund names and categories are configurable per institution.

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