Bonaventure OgetoBy Bonaventure Ogeto|

Building a SACCO Loan Repayment Collection System

To build a SACCO loan repayment system with Paystack, create loan records with amortization schedules, generate monthly installment invoices, collect payments via M-Pesa through the Charge API, track partial payments, flag late installments, and generate reports for SACCO management. Use webhooks to confirm payments and update loan balances automatically. For compliance, consider SASRA regulations and ensure your system produces the reports a licensed SACCO needs.

Understanding the SACCO Context

Savings and Credit Cooperative Organizations (SACCOs) are a cornerstone of Kenya's financial system. There are over 170 licensed deposit-taking SACCOs in Kenya, plus thousands of non-deposit-taking ones. They provide savings accounts, loans, and financial services to millions of Kenyans who may not have access to or may not qualify for traditional bank products.

SACCOs make money primarily through lending. A member saves with the SACCO, builds up their shares, and can then borrow a multiple of their savings. The loan is repaid in monthly installments with interest. The SACCO's financial health depends on collecting these repayments reliably.

Most SACCOs still collect loan repayments through one of these methods:

  • Check-off. The repayment is deducted from the member's salary before they receive it. This works for employed members but not for self-employed or gig workers.
  • Standing order. The member's bank automatically transfers the repayment monthly. Depends on the member having a bank account with sufficient balance.
  • M-Pesa to the SACCO's paybill. The member sends money manually each month. This works but has no automation, no reminders, and no tracking beyond matching M-Pesa statements to member accounts.

A digital collection system adds automation, tracking, and convenience to the third option. Members receive an STK push, enter their PIN, and the payment is automatically matched to their loan account. The SACCO gets real-time visibility into collections.

Architecture Overview

The loan repayment system sits between the SACCO's core banking system (or spreadsheet) and the payment processor. It manages three things:

  1. Loan lifecycle. Creating loans, generating amortization schedules, tracking balances, and closing loans when fully repaid.
  2. Payment collection. Triggering M-Pesa charges for each installment, processing payments, and handling partial payments and failures.
  3. Reporting. Producing the reports the SACCO management and SASRA need: collection rates, arrears aging, loan portfolio quality, and member statements.

The Paystack integration is limited to the payment collection layer. You use the Charge API to collect installments via M-Pesa and the Transfers API if you need to disburse loan proceeds to members' M-Pesa wallets. Webhooks confirm payments and update loan balances.

If the SACCO has an existing core banking system (like Navision, Bankers Realm, or a custom system), your payment module feeds data into it. You do not replace the core banking system. You add a digital payment channel to it.

Data Model

CREATE TABLE sacco_members (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  member_number VARCHAR(20) UNIQUE NOT NULL, -- SACCO-assigned number
  name VARCHAR(255) NOT NULL,
  phone VARCHAR(20) NOT NULL,
  email VARCHAR(255),
  national_id VARCHAR(20),
  employer VARCHAR(255),
  shares_balance_cents BIGINT DEFAULT 0,
  is_active BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE loans (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  member_id UUID REFERENCES sacco_members(id) NOT NULL,
  loan_number VARCHAR(30) UNIQUE NOT NULL,
  principal_cents BIGINT NOT NULL,
  interest_rate DECIMAL(5,2) NOT NULL, -- annual rate, e.g., 12.00 for 12%
  interest_type VARCHAR(20) DEFAULT 'reducing_balance', -- reducing_balance, flat_rate
  term_months INTEGER NOT NULL,
  monthly_installment_cents INTEGER NOT NULL,
  total_interest_cents BIGINT NOT NULL,
  total_repayable_cents BIGINT NOT NULL,
  outstanding_balance_cents BIGINT NOT NULL,
  disbursed_at TIMESTAMPTZ,
  first_repayment_date DATE NOT NULL,
  status VARCHAR(20) DEFAULT 'active', -- active, fully_paid, defaulted, written_off
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE loan_schedule (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  loan_id UUID REFERENCES loans(id) NOT NULL,
  installment_number INTEGER NOT NULL,
  due_date DATE NOT NULL,
  principal_cents INTEGER NOT NULL,
  interest_cents INTEGER NOT NULL,
  total_due_cents INTEGER NOT NULL,
  amount_paid_cents INTEGER DEFAULT 0,
  status VARCHAR(20) DEFAULT 'pending', -- pending, partial, paid, overdue, waived
  grace_period_end DATE,
  penalty_cents INTEGER DEFAULT 0,
  paid_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(loan_id, installment_number)
);

CREATE TABLE loan_payments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  loan_id UUID REFERENCES loans(id) NOT NULL,
  schedule_id UUID REFERENCES loan_schedule(id),
  amount_cents INTEGER NOT NULL,
  payment_method VARCHAR(20) DEFAULT 'mpesa', -- mpesa, bank, checkoff, cash
  paystack_reference VARCHAR(255),
  receipt_number VARCHAR(50),
  applied_to_principal_cents INTEGER DEFAULT 0,
  applied_to_interest_cents INTEGER DEFAULT 0,
  applied_to_penalty_cents INTEGER DEFAULT 0,
  status VARCHAR(20) DEFAULT 'confirmed', -- confirmed, reversed
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE collection_attempts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  schedule_id UUID REFERENCES loan_schedule(id) NOT NULL,
  member_phone VARCHAR(20) NOT NULL,
  amount_cents INTEGER NOT NULL,
  paystack_reference VARCHAR(255),
  status VARCHAR(20) DEFAULT 'initiated', -- initiated, completed, failed, expired
  failure_reason VARCHAR(500),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

Key design decisions:

  • Separate loan_schedule from loan_payments. The schedule defines what is due. Payments record what was actually paid. A single schedule entry can have multiple payments (for partial payments across different dates).
  • applied_to_principal, applied_to_interest, applied_to_penalty on each payment breaks down how the money was allocated. This is critical for accounting and for the member to understand where their money went.
  • collection_attempts tracks every STK push you send. If a member gets 3 STK pushes and only completes 1, you need to know.
  • interest_type matters. Reducing balance means interest is calculated on the declining principal. Flat rate means interest is calculated on the original principal for the full term. Most modern SACCOs use reducing balance, but some smaller ones still use flat rate. Your system needs to support both.

Loan Schedule Generation

When a loan is approved, generate the full amortization schedule. This tells the member (and the system) exactly how much is due each month, broken down into principal and interest.

function generateAmortizationSchedule(loan) {
  const schedule = [];
  const monthlyRate = loan.interestRate / 100 / 12;
  let remainingPrincipal = loan.principalCents;

  if (loan.interestType === 'reducing_balance') {
    // Standard amortization formula
    const monthlyPayment = Math.ceil(
      (loan.principalCents * monthlyRate * Math.pow(1 + monthlyRate, loan.termMonths)) /
      (Math.pow(1 + monthlyRate, loan.termMonths) - 1)
    );

    for (let i = 1; i <= loan.termMonths; i++) {
      const interestCents = Math.ceil(remainingPrincipal * monthlyRate);
      const principalCents = Math.min(monthlyPayment - interestCents, remainingPrincipal);
      const totalDue = principalCents + interestCents;
      remainingPrincipal -= principalCents;

      const dueDate = new Date(loan.firstRepaymentDate);
      dueDate.setMonth(dueDate.getMonth() + (i - 1));

      const gracePeriodEnd = new Date(dueDate);
      gracePeriodEnd.setDate(gracePeriodEnd.getDate() + 5); // 5-day grace period

      schedule.push({
        installmentNumber: i,
        dueDate,
        principalCents,
        interestCents,
        totalDueCents: totalDue,
        gracePeriodEnd,
      });
    }
  } else if (loan.interestType === 'flat_rate') {
    // Flat rate: interest on original principal for full term
    const totalInterest = Math.ceil(loan.principalCents * (loan.interestRate / 100) * (loan.termMonths / 12));
    const monthlyInterest = Math.ceil(totalInterest / loan.termMonths);
    const monthlyPrincipal = Math.ceil(loan.principalCents / loan.termMonths);

    for (let i = 1; i <= loan.termMonths; i++) {
      const dueDate = new Date(loan.firstRepaymentDate);
      dueDate.setMonth(dueDate.getMonth() + (i - 1));

      const gracePeriodEnd = new Date(dueDate);
      gracePeriodEnd.setDate(gracePeriodEnd.getDate() + 5);

      schedule.push({
        installmentNumber: i,
        dueDate,
        principalCents: monthlyPrincipal,
        interestCents: monthlyInterest,
        totalDueCents: monthlyPrincipal + monthlyInterest,
        gracePeriodEnd,
      });
    }
  }

  return schedule;
}

// Create a loan and its schedule
app.post('/api/loans', async (req, res) => {
  const { memberId, principalCents, interestRate, interestType, termMonths, firstRepaymentDate } = req.body;

  const schedule = generateAmortizationSchedule({
    principalCents, interestRate, interestType, termMonths, firstRepaymentDate,
  });

  const totalInterest = schedule.reduce((sum, s) => sum + s.interestCents, 0);
  const totalRepayable = principalCents + totalInterest;
  const monthlyInstallment = schedule[0].totalDueCents;

  const loanNumber = `LN-${Date.now()}`;

  const loan = await db.query(
    `INSERT INTO loans
     (member_id, loan_number, principal_cents, interest_rate, interest_type,
      term_months, monthly_installment_cents, total_interest_cents, total_repayable_cents,
      outstanding_balance_cents, first_repayment_date, disbursed_at)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW())
     RETURNING *`,
    [memberId, loanNumber, principalCents, interestRate, interestType,
     termMonths, monthlyInstallment, totalInterest, totalRepayable,
     totalRepayable, firstRepaymentDate]
  );

  // Insert schedule entries
  for (const entry of schedule) {
    await db.query(
      `INSERT INTO loan_schedule
       (loan_id, installment_number, due_date, principal_cents, interest_cents, total_due_cents, grace_period_end)
       VALUES ($1, $2, $3, $4, $5, $6, $7)`,
      [loan.rows[0].id, entry.installmentNumber, entry.dueDate,
       entry.principalCents, entry.interestCents, entry.totalDueCents, entry.gracePeriodEnd]
    );
  }

  res.json({ loan: loan.rows[0], schedule });
});

A note on interest calculation: Kenyan SACCOs under SASRA are required to disclose the effective interest rate and use methods that are transparent to members. If you are building for a SASRA-regulated SACCO, confirm with their compliance team which interest calculation method they use and ensure your implementation matches exactly. Rounding differences of a few shillings per installment compound over a 36-month loan and cause reconciliation headaches.

Collection Engine

The collection engine runs on a schedule (daily) and triggers M-Pesa charges for installments that are due. It also handles reminders and late payment tracking.

async function runDailyCollection() {
  const today = new Date().toISOString().split('T')[0];

  // 1. Send reminders for installments due in 3 days
  const upcoming = await db.query(
    `SELECT ls.*, l.loan_number, sm.name, sm.phone
     FROM loan_schedule ls
     JOIN loans l ON ls.loan_id = l.id
     JOIN sacco_members sm ON l.member_id = sm.id
     WHERE ls.due_date = (CURRENT_DATE + INTERVAL '3 days')
       AND ls.status = 'pending'`
  );

  for (const installment of upcoming.rows) {
    const amount = (installment.total_due_cents - installment.amount_paid_cents) / 100;
    await sendSMS(
      installment.phone,
      `Dear ${installment.name}, your loan ${installment.loan_number} installment of KES ${amount.toLocaleString()} is due on ${installment.due_date}. Please ensure sufficient M-Pesa balance.`
    );
  }

  // 2. Trigger charges for installments due today
  const dueToday = await db.query(
    `SELECT ls.*, l.loan_number, l.id as loan_id, sm.name, sm.phone, sm.email
     FROM loan_schedule ls
     JOIN loans l ON ls.loan_id = l.id
     JOIN sacco_members sm ON l.member_id = sm.id
     WHERE ls.due_date = CURRENT_DATE
       AND ls.status IN ('pending', 'partial')
       AND l.status = 'active'`
  );

  for (const installment of dueToday.rows) {
    const amountToCollect = installment.total_due_cents - installment.amount_paid_cents;
    if (amountToCollect <= 0) continue;

    await initiateInstallmentCharge(installment, amountToCollect);
  }

  // 3. Mark overdue installments past grace period
  await db.query(
    `UPDATE loan_schedule
     SET status = 'overdue'
     WHERE status = 'pending'
       AND grace_period_end < CURRENT_DATE`
  );

  // 4. Apply penalties to overdue installments
  await applyLatePenalties();
}

async function initiateInstallmentCharge(installment, amountCents) {
  try {
    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: installment.email || `${installment.phone}@sacco.local`,
        amount: amountCents,
        currency: 'KES',
        mobile_money: {
          phone: installment.phone,
          provider: 'mpesa',
        },
        metadata: {
          schedule_id: installment.id,
          loan_id: installment.loan_id,
          loan_number: installment.loan_number,
          installment_number: installment.installment_number,
          member_name: installment.name,
        },
      }),
    });

    const chargeData = await chargeRes.json();

    await db.query(
      `INSERT INTO collection_attempts
       (schedule_id, member_phone, amount_cents, paystack_reference, status)
       VALUES ($1, $2, $3, $4, $5)`,
      [installment.id, installment.phone, amountCents,
       chargeData.data?.reference, chargeData.status ? 'initiated' : 'failed']
    );
  } catch (err) {
    console.error('Collection attempt failed:', installment.loan_number, err.message);
  }
}

async function applyLatePenalties() {
  // Find overdue installments without penalties yet
  const overdue = await db.query(
    `SELECT ls.*, l.id as loan_id
     FROM loan_schedule ls
     JOIN loans l ON ls.loan_id = l.id
     WHERE ls.status = 'overdue'
       AND ls.penalty_cents = 0
       AND ls.grace_period_end < CURRENT_DATE
       AND l.status = 'active'`
  );

  for (const installment of overdue.rows) {
    // Penalty: configurable per SACCO rules
    // Example: 5% of overdue amount or a flat fee
    const penaltyCents = Math.ceil((installment.total_due_cents - installment.amount_paid_cents) * 0.05);

    await db.query(
      'UPDATE loan_schedule SET penalty_cents = $1 WHERE id = $2',
      [penaltyCents, installment.id]
    );

    // Add penalty to outstanding balance
    await db.query(
      'UPDATE loans SET outstanding_balance_cents = outstanding_balance_cents + $1 WHERE id = $2',
      [penaltyCents, installment.loan_id]
    );
  }
}

The timing of STK pushes matters. Send them during business hours (8 AM to 6 PM EAT). A member who gets an STK push at 6 AM while asleep will ignore it and then forget about it. Send it when they are likely to have their phone in hand and M-Pesa float available.

Payment Processing and Partial Payments

When a payment comes through the webhook, you need to apply it correctly: first to penalties, then to interest, then to principal. This is the standard loan payment application order, and most SACCO regulations require it.

async function handleLoanPayment(data) {
  const { schedule_id, loan_id, installment_number } = data.metadata;
  if (!schedule_id || !loan_id) return;

  const schedule = await db.query('SELECT * FROM loan_schedule WHERE id = $1', [schedule_id]);
  if (!schedule.rows[0]) return;

  const installment = schedule.rows[0];
  let remainingPayment = data.amount;

  // Apply payment in order: penalty, interest, principal
  let appliedToPenalty = 0;
  let appliedToInterest = 0;
  let appliedToPrincipal = 0;

  // 1. Pay off any penalty first
  if (installment.penalty_cents > 0) {
    const penaltyPayment = Math.min(remainingPayment, installment.penalty_cents);
    appliedToPenalty = penaltyPayment;
    remainingPayment -= penaltyPayment;
  }

  // 2. Pay interest
  const outstandingInterest = installment.interest_cents -
    (installment.amount_paid_cents > installment.penalty_cents
      ? Math.min(installment.amount_paid_cents - installment.penalty_cents, installment.interest_cents)
      : 0);
  if (outstandingInterest > 0 && remainingPayment > 0) {
    const interestPayment = Math.min(remainingPayment, outstandingInterest);
    appliedToInterest = interestPayment;
    remainingPayment -= interestPayment;
  }

  // 3. Pay principal with whatever remains
  if (remainingPayment > 0) {
    appliedToPrincipal = remainingPayment;
    remainingPayment = 0;
  }

  // Record the payment
  const receiptNumber = `RCP-${Date.now()}`;
  await db.query(
    `INSERT INTO loan_payments
     (loan_id, schedule_id, amount_cents, payment_method, paystack_reference,
      receipt_number, applied_to_principal_cents, applied_to_interest_cents, applied_to_penalty_cents)
     VALUES ($1, $2, $3, 'mpesa', $4, $5, $6, $7, $8)`,
    [loan_id, schedule_id, data.amount, data.reference, receiptNumber,
     appliedToPrincipal, appliedToInterest, appliedToPenalty]
  );

  // Update schedule
  const newPaidAmount = installment.amount_paid_cents + data.amount;
  const totalOwed = installment.total_due_cents + installment.penalty_cents;
  const isPaidInFull = newPaidAmount >= totalOwed;

  await db.query(
    `UPDATE loan_schedule
     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', schedule_id]
  );

  // Update loan outstanding balance
  await db.query(
    'UPDATE loans SET outstanding_balance_cents = outstanding_balance_cents - $1 WHERE id = $2',
    [data.amount, loan_id]
  );

  // Check if loan is fully repaid
  const loan = await db.query('SELECT * FROM loans WHERE id = $1', [loan_id]);
  if (loan.rows[0].outstanding_balance_cents <= 0) {
    await db.query(
      "UPDATE loans SET status = 'fully_paid', outstanding_balance_cents = 0 WHERE id = $1",
      [loan_id]
    );
  }

  // Send receipt SMS
  const member = await db.query(
    'SELECT sm.* FROM sacco_members sm JOIN loans l ON l.member_id = sm.id WHERE l.id = $1',
    [loan_id]
  );
  if (member.rows[0]) {
    await sendSMS(
      member.rows[0].phone,
      `Payment received. KES ${data.amount / 100} applied to loan ${loan.rows[0].loan_number}. Outstanding: KES ${Math.max(0, loan.rows[0].outstanding_balance_cents - data.amount) / 100}. Receipt: ${receiptNumber}`
    );
  }
}

Partial payments are common in SACCOs. A member might be able to pay KES 3,000 of a KES 5,000 installment this week and the remaining KES 2,000 next week. Your system should accept the partial payment, update the installment accordingly, and allow subsequent payments against the same installment until it is paid in full.

Reporting for SACCO Officials

SACCO management needs specific reports to run the lending operation and to satisfy regulatory requirements. Your system should generate these on demand.

// Loan portfolio summary
app.get('/api/reports/portfolio', async (req, res) => {
  const portfolio = await db.query(`
    SELECT
      COUNT(*) as total_loans,
      COUNT(*) FILTER (WHERE status = 'active') as active_loans,
      COUNT(*) FILTER (WHERE status = 'fully_paid') as fully_paid_loans,
      COUNT(*) FILTER (WHERE status = 'defaulted') as defaulted_loans,
      SUM(principal_cents) as total_disbursed_cents,
      SUM(outstanding_balance_cents) FILTER (WHERE status = 'active') as total_outstanding_cents,
      SUM(total_interest_cents) as total_interest_expected_cents
    FROM loans
  `);

  // Arrears aging report
  const arrears = await db.query(`
    SELECT
      COUNT(*) FILTER (WHERE CURRENT_DATE - due_date <= 30) as days_1_30,
      COUNT(*) FILTER (WHERE CURRENT_DATE - due_date BETWEEN 31 AND 60) as days_31_60,
      COUNT(*) FILTER (WHERE CURRENT_DATE - due_date BETWEEN 61 AND 90) as days_61_90,
      COUNT(*) FILTER (WHERE CURRENT_DATE - due_date > 90) as days_over_90,
      SUM(total_due_cents - amount_paid_cents) FILTER (WHERE CURRENT_DATE - due_date <= 30) as amount_1_30,
      SUM(total_due_cents - amount_paid_cents) FILTER (WHERE CURRENT_DATE - due_date BETWEEN 31 AND 60) as amount_31_60,
      SUM(total_due_cents - amount_paid_cents) FILTER (WHERE CURRENT_DATE - due_date BETWEEN 61 AND 90) as amount_61_90,
      SUM(total_due_cents - amount_paid_cents) FILTER (WHERE CURRENT_DATE - due_date > 90) as amount_over_90
    FROM loan_schedule
    WHERE status = 'overdue'
  `);

  // Collection rate this month
  const thisMonth = await db.query(`
    SELECT
      SUM(total_due_cents) as expected_cents,
      SUM(amount_paid_cents) as collected_cents
    FROM loan_schedule
    WHERE DATE_TRUNC('month', due_date) = DATE_TRUNC('month', CURRENT_DATE)
  `);

  const collectionRate = thisMonth.rows[0].expected_cents > 0
    ? (thisMonth.rows[0].collected_cents / thisMonth.rows[0].expected_cents * 100).toFixed(1)
    : 0;

  res.json({
    portfolio: portfolio.rows[0],
    arrears_aging: arrears.rows[0],
    collection_rate: `${collectionRate}%`,
    expected_this_month: thisMonth.rows[0].expected_cents,
    collected_this_month: thisMonth.rows[0].collected_cents,
  });
});

// Individual member loan statement
app.get('/api/members/:memberId/statement', async (req, res) => {
  const member = await db.query('SELECT * FROM sacco_members WHERE id = $1', [req.params.memberId]);
  const loans = await db.query('SELECT * FROM loans WHERE member_id = $1 ORDER BY created_at DESC', [req.params.memberId]);

  const statements = [];
  for (const loan of loans.rows) {
    const schedule = await db.query(
      'SELECT * FROM loan_schedule WHERE loan_id = $1 ORDER BY installment_number',
      [loan.id]
    );
    const payments = await db.query(
      'SELECT * FROM loan_payments WHERE loan_id = $1 ORDER BY created_at',
      [loan.id]
    );

    statements.push({
      loan,
      schedule: schedule.rows,
      payments: payments.rows,
    });
  }

  res.json({ member: member.rows[0], statements });
});

The reports SACCO officials need most:

  • Collection rate. What percentage of expected repayments were actually collected this month? A healthy SACCO targets 95%+.
  • Arrears aging. How much money is overdue, and for how long? Categorize into 1-30 days, 31-60, 61-90, and 90+ days. This is a SASRA reporting requirement for deposit-taking SACCOs.
  • Portfolio at risk (PAR). What percentage of the outstanding loan portfolio has payments overdue by more than 30 days? PAR30 is the standard measure.
  • Member statements. Individual loan statements showing every installment, payment, and the current balance. Members request these regularly.
  • Daily collection summary. How much was collected today, broken down by M-Pesa, bank, and check-off.

Integration with Existing SACCO Software

Most SACCOs you work with will not be starting from scratch. They have an existing system, even if it is just Excel. Your payment collection module needs to work with what they already have.

Common integration patterns:

  • CSV import/export. The simplest integration. The SACCO exports their loan data as CSV. Your system imports it. After collecting payments, your system exports the payment records as CSV for the SACCO to import into their core system. This is low-tech but works for SACCOs that do not have an API.
  • Database synchronization. If the SACCO's core system uses a database you can access (SQL Server for Navision, MySQL, etc.), set up a sync process that reads loan data and writes back payment records on a schedule.
  • API integration. If the core banking system has an API (some modern SACCO software does), build a proper API integration that syncs in real time.
  • Manual reconciliation. For very small SACCOs, the treasurer downloads the payment report from your system and manually enters it into their records. Not ideal, but it is where many SACCOs start.
// CSV export of payments for a given period
app.get('/api/reports/payments/csv', async (req, res) => {
  const { startDate, endDate } = req.query;

  const payments = await db.query(
    `SELECT
       lp.receipt_number,
       lp.created_at as payment_date,
       sm.member_number,
       sm.name as member_name,
       l.loan_number,
       lp.amount_cents,
       lp.applied_to_principal_cents,
       lp.applied_to_interest_cents,
       lp.applied_to_penalty_cents,
       lp.payment_method,
       lp.paystack_reference
     FROM loan_payments lp
     JOIN loans l ON lp.loan_id = l.id
     JOIN sacco_members sm ON l.member_id = sm.id
     WHERE lp.created_at >= $1 AND lp.created_at <= $2
     ORDER BY lp.created_at`,
    [startDate, endDate]
  );

  // Convert to CSV
  const headers = 'Receipt,Date,Member No,Name,Loan No,Amount,Principal,Interest,Penalty,Method,Reference\n';
  const rows = payments.rows.map(p =>
    `${p.receipt_number},${p.payment_date},${p.member_number},${p.member_name},${p.loan_number},${p.amount_cents / 100},${p.applied_to_principal_cents / 100},${p.applied_to_interest_cents / 100},${p.applied_to_penalty_cents / 100},${p.payment_method},${p.paystack_reference}`
  ).join('\n');

  res.setHeader('Content-Type', 'text/csv');
  res.setHeader('Content-Disposition', `attachment; filename=payments-${startDate}-to-${endDate}.csv`);
  res.send(headers + rows);
});

Start with CSV. It is the universal integration format. Every SACCO system can import CSV. Once you prove the value of automated collection, the SACCO will invest in a deeper integration.

Compliance Considerations

SACCO software in Kenya operates under regulatory oversight. Getting compliance wrong can get the SACCO sanctioned. Here is what you need to consider.

SASRA Regulations. Deposit-taking SACCOs are licensed and supervised by the SACCO Societies Regulatory Authority (SASRA). SASRA mandates specific reporting formats, capital adequacy ratios, and operational procedures. Your software must produce reports in the formats SASRA requires. Talk to the SACCO's compliance officer before building the reporting module.

Data Protection Act. Member financial data (loan amounts, payment history, phone numbers) is personal data under Kenya's Data Protection Act. You need explicit consent to collect and process it. Store it securely. Do not share it with third parties without consent. Have a data processing agreement with the SACCO.

Consumer protection. Members must be able to see their loan terms, interest calculation, and payment history clearly. The system should make this information easily accessible, not buried behind complex menus.

Receipt generation. Every payment must generate a receipt that the member can reference. Include the receipt number, amount paid, breakdown (principal, interest, penalty), outstanding balance, and date. Some SACCOs need eTIMS-compliant receipts for tax purposes.

Audit trail. Store every action that changes a financial record: loan creation, payment receipt, penalty application, loan write-off. Every record should have a timestamp, the user who initiated it, and the before/after state. SASRA auditors will look at this.

Interest rate disclosure. The SACCO must disclose the effective interest rate to members. Your loan creation screen should calculate and display the effective annual rate, not just the nominal rate. This is a consumer protection requirement.

Deployment Notes

Start small. Do not try to replace the SACCO's entire core banking system. Start with the payment collection module. Prove that automated M-Pesa collection works better than manual paybill monitoring. Then expand to loan management, member management, and reporting.

On-premise concerns. Some SACCOs are uncomfortable with cloud-hosted software for financial data. They may want the system on their own server. If that is the case, consider a hybrid approach: the collection engine runs in the cloud (because Paystack webhooks need a public URL), but the loan data syncs to an on-premise database that the SACCO controls.

Backup and disaster recovery. Financial data cannot be lost. Set up automated daily backups. Test restoring from backup regularly. If you are on a managed database service (Supabase, Neon, AWS RDS), enable point-in-time recovery.

SMS costs at scale. A SACCO with 5,000 members and monthly reminders (3 per member per cycle) sends 15,000 SMS per month. Budget for this. Negotiate bulk SMS rates with Africa's Talking or your SMS provider.

Training. SACCO staff need training on the system. Build a simple admin interface with clear labels and minimal complexity. The loan officer who has been using paper ledgers for 20 years is not going to learn a complex UI. Make it obvious.

For the Paystack Kenya context, see Paystack in Kenya: M-Pesa, Pesalink, and the Daraja Question. For webhook handling, see Paystack Webhooks: Complete Engineering Guide.

If you want to build SACCO or fintech software with proper mentorship, the McTaba 26-week bootcamp (KES 120,000) covers payment integration, database design, and real-world product development. The M-Pesa micro-course covers Daraja if you decide direct integration is the better fit for the SACCO's needs.

Key Takeaways

  • SACCOs are regulated by SASRA (Sacco Societies Regulatory Authority) in Kenya. Your software must produce reports that comply with SASRA requirements. This is not optional.
  • Loan repayment collection through Paystack works the same as any M-Pesa charge. The difference is in the business logic: amortization schedules, interest tracking, and partial payment handling.
  • M-Pesa does not support automatic debit. Each monthly installment requires a fresh STK push that the member authorizes. Your system triggers the charge and handles the case where the member does not complete it.
  • Partial payments are common in SACCOs. A member who owes KES 5,000 this month might only have KES 3,000. Your system should accept the partial payment, credit it to the loan, and track the remaining balance.
  • Late payment tracking drives the dunning and penalty workflow. Define a grace period (e.g., 5 days after due date), penalty rules, and escalation steps for chronic defaulters.
  • Integration with existing SACCO software is the real-world challenge. Most SACCOs already run on desktop software or spreadsheets. Your payment system needs to feed data into their existing workflow, not replace it overnight.

Frequently Asked Questions

Can Paystack automatically collect SACCO loan repayments each month without member action?
No. M-Pesa does not support automatic direct debit. Each month, you need to trigger an STK push and the member must enter their M-Pesa PIN to authorize the payment. For card-based repayments, Paystack can auto-charge using stored card authorizations, but most SACCO members in Kenya pay via M-Pesa.
How do I handle a member who overpays on an installment?
Apply the excess to the next installment in the schedule. If the member overpays the last installment, the loan balance goes negative (overpayment). You can refund the overpayment via Paystack Transfer to the member's M-Pesa, or credit it to their SACCO savings account. Document the SACCO's policy on overpayments in the system configuration.
Should we use Paystack or direct Daraja for SACCO collections?
If M-Pesa is the only collection method and the SACCO processes high volumes, direct Daraja may be more cost-effective. Paystack is better if you also need card payments, want a dashboard without building one, or plan to add other payment methods. Many SACCOs start with Paystack for convenience and move to Daraja (or run both) as volumes grow.
What reports does SASRA require from deposit-taking SACCOs?
SASRA requires periodic reports including portfolio quality reports (PAR analysis), arrears aging, capital adequacy calculations, and financial statements. The exact formats and frequencies change, so check the current SASRA guidelines. Your software should at minimum produce arrears aging (1-30, 31-60, 61-90, 90+ days), PAR30 ratio, and collection rate reports.
How do I handle check-off payments alongside M-Pesa collections?
Check-off payments (salary deductions) bypass Paystack entirely. The employer deducts the amount from the member's salary and remits it to the SACCO. Your system should allow manual entry of check-off payments so that the loan balance updates correctly. Build an import function for check-off remittance files from employers. The loan_payments table already has a payment_method field to distinguish between mpesa, bank, checkoff, and cash.

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