Loan Repayment Collection with Paystack Recurring Charges
Use Paystack charge_authorization for loan repayment collection because repayment amounts may include varying interest, borrowers may make partial payments, and the schedule is loan-specific, not a standard monthly interval. Collect the first payment during loan disbursement to capture the authorization code, then charge repayments on your schedule. Build robust delinquency handling because missed loan payments have regulatory and compliance implications.
The Repayment Data Model
CREATE TABLE loans (
id SERIAL PRIMARY KEY,
borrower_user_id INTEGER REFERENCES users(id),
principal INTEGER NOT NULL,
interest_rate NUMERIC NOT NULL,
tenure_months INTEGER NOT NULL,
total_repayment INTEGER NOT NULL,
disbursed_at TIMESTAMPTZ,
status VARCHAR(20) DEFAULT 'active',
payment_method_id INTEGER REFERENCES payment_methods(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE repayment_schedule (
id SERIAL PRIMARY KEY,
loan_id INTEGER REFERENCES loans(id),
instalment_number INTEGER NOT NULL,
principal_portion INTEGER NOT NULL,
interest_portion INTEGER NOT NULL,
total_due INTEGER NOT NULL,
due_date DATE NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
amount_paid INTEGER DEFAULT 0,
paid_at TIMESTAMPTZ,
paystack_reference VARCHAR(100),
late_fee INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_repayment_due ON repayment_schedule(due_date, status);
Processing Repayments on Schedule
// Run daily
async function processRepayments() {
var due = await db.query(
'SELECT rs.id, rs.total_due, rs.late_fee, rs.instalment_number, l.id as loan_id, l.borrower_user_id, l.payment_method_id, u.email FROM repayment_schedule rs JOIN loans l ON rs.loan_id = l.id JOIN users u ON l.borrower_user_id = u.id WHERE rs.due_date <= CURRENT_DATE AND rs.status = $1 AND l.status = $2',
['pending', 'active']
);
for (var i = 0; i < due.rows.length; i++) {
var repayment = due.rows[i];
var chargeAmount = repayment.total_due + repayment.late_fee;
var authCode = await getAuthorizationCode(repayment.payment_method_id);
var reference = 'LOAN_' + repayment.loan_id + '_INST_' + repayment.instalment_number + '_' + Date.now();
// Log every attempt for audit trail
await db.query(
'INSERT INTO repayment_attempts (repayment_id, amount, reference, attempted_at) VALUES ($1, $2, $3, NOW())',
[repayment.id, chargeAmount, reference]
);
var result = await chargeAuthorization(repayment.email, chargeAmount, authCode, reference);
if (result.status === 'success') {
await db.query(
'UPDATE repayment_schedule SET status = $1, amount_paid = $2, paid_at = NOW(), paystack_reference = $3 WHERE id = $4',
['paid', chargeAmount, reference, repayment.id]
);
// Check if loan is fully repaid
await checkLoanCompletion(repayment.loan_id);
await sendRepaymentReceiptSMS(repayment.email, chargeAmount, repayment.instalment_number);
} else {
await db.query(
'UPDATE repayment_schedule SET status = $1 WHERE id = $2',
['overdue', repayment.id]
);
await handleRepaymentFailure(repayment, result.gateway_response);
}
}
}
async function checkLoanCompletion(loanId) {
var remaining = await db.query(
'SELECT COUNT(*) as count FROM repayment_schedule WHERE loan_id = $1 AND status != $2',
[loanId, 'paid']
);
if (parseInt(remaining.rows[0].count) === 0) {
await db.query('UPDATE loans SET status = $1 WHERE id = $2', ['completed', loanId]);
var loan = await db.query('SELECT borrower_user_id FROM loans WHERE id = $1', [loanId]);
await sendLoanCompletionNotification(loan.rows[0].borrower_user_id);
}
}
Handling Delinquent Repayments
Missed loan repayments have more serious consequences than missed SaaS subscriptions. Handle with appropriate gravity but also compliance.
async function handleRepaymentFailure(repayment, gatewayResponse) {
var category = categorizeFailure(gatewayResponse);
// Log for compliance audit trail
await db.query(
'INSERT INTO delinquency_log (loan_id, repayment_id, reason, category, logged_at) VALUES ($1, $2, $3, $4, NOW())',
[repayment.loan_id, repayment.id, gatewayResponse, category]
);
if (category === 'temporary') {
// Retry in 3 days
await scheduleRepaymentRetry(repayment.id, 3);
await sendRepaymentFailedNotification(repayment.email, {
type: 'temporary',
message: 'Your repayment could not be processed. We will retry in 3 days. Please ensure funds are available.',
});
} else if (category === 'permanent') {
// Card is unusable. Request a new card.
await sendRepaymentFailedNotification(repayment.email, {
type: 'card_issue',
message: 'Your card could not be charged. Please update your payment method to avoid late fees.',
updateUrl: 'https://yourlendingplatform.com/update-card',
});
}
// Apply late fee if configured and repayment is past the grace period
var daysPastDue = Math.floor(
(Date.now() - new Date(repayment.due_date).getTime()) / (24 * 60 * 60 * 1000)
);
if (daysPastDue > 7 && repayment.late_fee === 0) {
var lateFee = calculateLateFee(repayment.total_due);
await db.query(
'UPDATE repayment_schedule SET late_fee = $1 WHERE id = $2',
[lateFee, repayment.id]
);
}
}
Compliance considerations for lending:
- Late fee policies must comply with local regulations. Nigeria's FCCPC and Kenya's CBK have guidelines on lending practices.
- Log every charge attempt, communication, and late fee application. Regulators may audit your records.
- Do not use aggressive or threatening language in collection communications.
- Credit reporting obligations may apply. If you report to credit bureaus, missed payments must be reported accurately.
Handling Early and Extra Repayments
async function processEarlyRepayment(loanId, extraAmount) {
// Apply extra payment to the next pending instalment(s)
var pendingInstalments = await db.query(
'SELECT id, total_due, amount_paid FROM repayment_schedule WHERE loan_id = $1 AND status = $2 ORDER BY due_date ASC',
[loanId, 'pending']
);
var remaining = extraAmount;
for (var i = 0; i < pendingInstalments.rows.length && remaining > 0; i++) {
var instalment = pendingInstalments.rows[i];
var owed = instalment.total_due - instalment.amount_paid;
var toApply = Math.min(remaining, owed);
var newPaid = instalment.amount_paid + toApply;
var newStatus = newPaid >= instalment.total_due ? 'paid' : 'partial';
await db.query(
'UPDATE repayment_schedule SET amount_paid = $1, status = $2, paid_at = CASE WHEN $2 = $3 THEN NOW() ELSE paid_at END WHERE id = $4',
[newPaid, newStatus, 'paid', instalment.id]
);
remaining = remaining - toApply;
}
await checkLoanCompletion(loanId);
return { applied: extraAmount - remaining, remaining: remaining };
}
Early repayment is a borrower-friendly feature. Allow it through your borrower portal where they can make additional payments. Use charge_authorization if they want to pay extra from their saved card, or let them pay through checkout for any amount.
Reporting and Audit Trail
async function getLoanCollectionReport(startDate, endDate) {
var report = {};
report.totalDue = await db.query(
'SELECT SUM(total_due) as amount FROM repayment_schedule WHERE due_date BETWEEN $1 AND $2',
[startDate, endDate]
);
report.totalCollected = await db.query(
'SELECT SUM(amount_paid) as amount FROM repayment_schedule WHERE paid_at BETWEEN $1 AND $2 AND status = $3',
[startDate, endDate, 'paid']
);
report.totalOverdue = await db.query(
'SELECT SUM(total_due - amount_paid) as amount FROM repayment_schedule WHERE due_date < CURRENT_DATE AND status IN ($1, $2)',
['pending', 'overdue']
);
report.collectionRate = parseInt(report.totalDue.rows[0].amount) > 0
? (parseInt(report.totalCollected.rows[0].amount) / parseInt(report.totalDue.rows[0].amount) * 100).toFixed(1) + '%'
: '0%';
return report;
}
Every lending business needs this data for regulatory reporting, investor updates, and portfolio health monitoring. Build these reports from day one, not as an afterthought.
Key Takeaways
- ✓Use charge_authorization, not the Subscriptions API, for loan repayments. Amounts vary (principal + interest), and schedules are loan-specific.
- ✓Capture the authorization during the first payment or during a verification charge at loan origination.
- ✓Track each repayment installment with a due date, expected amount, and payment status. This is your loan ledger.
- ✓Handle missed payments with care. Lending has regulatory requirements around collections, late fees, and credit reporting.
- ✓Build comprehensive logging for every charge attempt. You may need to produce this for regulatory audits.
- ✓Consider the borrower experience. Failed charges should trigger helpful notifications, not just automated retries.
Frequently Asked Questions
- Can I use the Paystack Subscriptions API for loan repayments?
- It is not ideal. Loan repayments often have variable amounts (changing principal/interest splits), non-standard intervals, and a fixed number of payments. Use charge_authorization for full control over amounts and timing.
- What if a borrower's card expires during the loan term?
- The next repayment charge will fail. Notify the borrower immediately and provide a link to add a new card. The outstanding repayment becomes overdue. This is why proactive card expiry monitoring is important for lending platforms.
- Do I need a regulatory license to collect loan repayments via Paystack?
- Collecting payments via Paystack is a payment processing function. However, lending itself requires licensing in most African countries. Nigeria requires CBN/FCCPC compliance. Kenya requires CBK licensing. Ensure your lending operations are properly licensed before building the collection system.
- How do I handle partial loan repayments?
- If the borrower can only pay part of the instalment, accept the partial amount and track the remaining balance. Update the instalment record to reflect the partial payment. The remaining balance carries forward or is added to the next instalment, depending on your loan terms.
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