Bonaventure OgetoBy Bonaventure Ogeto|

Build a Microfinance Repayment Portal

Build a microfinance repayment portal by disbursing loans via the Paystack Transfers API to borrowers' bank accounts, generating a repayment schedule on disbursement, sending a Paystack payment link for each repayment due, and optionally using card tokenization (authorization_code from first repayment) for auto-debit on subsequent installments.

Loan Disbursement and Repayment Schedule

Tables: borrowers (name, phone, id_number, bank_code, account_number, paystack_recipient_code, credit_score), loans (borrower_id, principal, interest_rate, term_weeks, weekly_installment, status, disbursed_at), repayments (loan_id, installment_number, due_date, amount_due, amount_paid, status, paystack_ref, paid_at).

function calculateInstallment(principal, interestRate, termWeeks) {
  var totalInterest = principal * interestRate;
  var total = principal + totalInterest;
  return Math.ceil(total / termWeeks);
}

async function disburseLoan(loanId) {
  var loan = await db.loans.findById(loanId);
  var borrower = await db.borrowers.findById(loan.borrower_id);

  // Ensure recipient code exists
  if (!borrower.paystack_recipient_code) {
    var rcpRes = 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: borrower.name, account_number: borrower.account_number, bank_code: borrower.bank_code, currency: 'NGN' }),
    });
    var code = (await rcpRes.json()).data.recipient_code;
    await db.borrowers.update(borrower.id, { paystack_recipient_code: code });
    borrower.paystack_recipient_code = code;
  }

  // Disburse
  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: loan.principal, recipient: borrower.paystack_recipient_code, reason: 'Loan disbursement #' + loanId }),
  });
  await db.loans.update(loanId, { status: 'disbursed', disbursed_at: new Date() });

  // Generate repayment schedule
  var installment = calculateInstallment(loan.principal, loan.interest_rate, loan.term_weeks);
  for (var i = 1; i <= loan.term_weeks; i++) {
    var dueDate = new Date();
    dueDate.setDate(dueDate.getDate() + (i * 7));
    var reference = 'REPAY-' + loanId + '-' + i;
    await db.repayments.create({ loan_id: loanId, installment_number: i, due_date: dueDate, amount_due: installment, status: 'pending', paystack_ref: reference });
  }
  await sendDisbursementNotification(borrower.phone, loan.principal / 100, loan.term_weeks);
}

Repayment Collection and Overdue Handling

// Send payment link for each installment due
async function sendRepaymentLink(borrowerEmail, repaymentId) {
  var repayment = await db.repayments.findById(repaymentId);
  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: borrowerEmail,
      amount: repayment.amount_due,
      currency: 'NGN',
      reference: repayment.paystack_ref,
      metadata: { repayment_id: repaymentId, loan_id: repayment.loan_id, installment: repayment.installment_number },
    }),
  });
  return (await res.json()).data.authorization_url;
}

// Webhook: record repayment
if (event.event === 'charge.success') {
  var meta = event.data.metadata;
  if (!meta.repayment_id) return res.sendStatus(200);

  await db.repayments.update(meta.repayment_id, {
    status: 'paid',
    amount_paid: event.data.amount,
    paid_at: new Date(),
  });

  // Check if loan is fully repaid
  var pendingCount = await db.repayments.countPending(meta.loan_id);
  if (pendingCount === 0) {
    await db.loans.update(meta.loan_id, { status: 'completed' });
    await notifyBorrowerLoanComplete(meta.loan_id);
  }
}

// Cron: flag overdue repayments and apply late penalty
async function processOverdueRepayments() {
  var graceDeadline = new Date();
  graceDeadline.setDate(graceDeadline.getDate() - 3); // 3-day grace

  var overdue = await db.repayments.findAll({ status: 'pending', due_date_lt: graceDeadline });
  var LATE_PENALTY_RATE = 0.02; // 2% of installment per week overdue

  for (var repayment of overdue) {
    var weeksOverdue = Math.floor((new Date() - new Date(repayment.due_date)) / (7 * 24 * 60 * 60 * 1000));
    var penalty = Math.floor(repayment.amount_due * LATE_PENALTY_RATE * weeksOverdue);
    await db.repayments.update(repayment.id, { status: 'overdue', late_penalty: penalty });
    await notifyBorrowerOverdue(repayment.loan_id, repayment.installment_number, penalty / 100);
  }
}

Learn More

See build a savings goal app with scheduled debits for card tokenization auto-debit — the same pattern used for recurring loan repayments.

Sign up for the McTaba newsletter

Key Takeaways

  • Disburse loans via Paystack Transfers — borrowers receive funds directly to their bank or M-Pesa.
  • Generate a repayment schedule at disbursement time: amount, due date, and balance per installment.
  • Use card tokenization (first repayment) to auto-debit subsequent installments without manual action.
  • Track overdue installments and apply penalty rates after a 3-day grace period.
  • Aggregate collection stats per loan officer or group for portfolio health monitoring.

Frequently Asked Questions

Do I need a lending licence to operate a microfinance repayment portal?
A repayment collection portal that processes payments on behalf of a licensed microfinance institution (MFI) is a technology service, not a lending activity. The MFI holds the lending licence and takes the credit risk. Your platform facilitates collection. If you are also underwriting and managing the loan risk yourself, you need a money lending licence from CBN (Nigeria) or CBK (Kenya).
How do I handle group loans (solidarity groups)?
For group loans, link multiple borrowers to a single loan. Each group member shares equal liability. Send one repayment link to the group leader who collects from members and makes a single payment. Alternatively, split the loan into sub-loans per member and track each member's repayment individually. The group model simplifies collection but complicates defaults — one member's default affects the whole group.
Can I use card auto-debit for repayments instead of sending payment links?
Yes. At the first repayment, use a standard Paystack checkout. Save the authorization_code from the charge.success webhook. For subsequent installments, use POST /charge/authorization with the saved code — no need for the borrower to take any action. Send an SMS notification before each auto-debit so the borrower knows to maintain sufficient funds. If a debit fails, send the manual payment link as a fallback.

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