Month-end close for a Paystack business follows this sequence: 1) Run final reconciliation for the last day of the month. 2) Verify all settlements for the month have arrived. 3) Confirm refund and chargeback totals. 4) Calculate total fees. 5) Verify that ledger totals match Paystack dashboard totals. 6) Produce revenue, expense, and cash flow reports. 7) Lock the period to prevent retroactive changes.
Month-end close is the process of finalizing your financial numbers for the month. When it is done, you can say with confidence: "In July 2026, we earned X in revenue, paid Y in processing fees, issued Z in refunds, and received W in cash deposits." These numbers go to your accountant, your investors, your tax filings, and your management reports.
For a business running on Paystack, the close involves reconciling three data sources:
If all three agree, your numbers are solid. If they disagree, you need to investigate and resolve the differences before closing the books.
Do not run the close on the first day of the new month. Wait 3-5 business days. Here is why:
For July 2026, start the close around August 4-5. By then, all July settlements should have arrived, late webhooks should have been processed, and your daily reconciliation jobs should have flagged any issues.
// Check if the month is ready for close
async function isMonthReadyForClose(year, month) {
var lastDay = new Date(year, month, 0); // Last day of the month
var today = new Date();
var businessDaysSince = countBusinessDays(lastDay, today);
if (businessDaysSince < 3) {
return {
ready: false,
reason: 'Only ' + businessDaysSince + ' business days since month end. Wait for settlements.',
};
}
// Check for unresolved reconciliation issues
var issues = await db.query(
'SELECT COUNT(*) as count FROM reconciliation_issues '
+ 'WHERE resolved = false AND created_at >= $1 AND created_at < $2',
[year + '-' + String(month).padStart(2, '0') + '-01',
year + '-' + String(month + 1).padStart(2, '0') + '-01']
);
if (parseInt(issues.rows[0].count) > 0) {
return {
ready: false,
reason: issues.rows[0].count + ' unresolved reconciliation issues.',
};
}
return { ready: true };
}
function countBusinessDays(from, to) {
var count = 0;
var current = new Date(from);
while (current < to) {
current.setDate(current.getDate() + 1);
var day = current.getDay();
if (day !== 0 && day !== 6) count++;
}
return count;
}
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Run your daily reconciliation job for every day of the month. If it has been running daily, check the results for any days that had issues. If it missed any days, run it retroactively now.
// Run reconciliation for every day of the month
async function monthlyReconciliation(year, month) {
var firstDay = new Date(year, month - 1, 1);
var lastDay = new Date(year, month, 0);
var results = [];
var current = new Date(firstDay);
while (current <= lastDay) {
var dateStr = current.toISOString().split('T')[0];
var dayResult = await reconcile(dateStr);
results.push({
date: dateStr,
matched: dayResult.matched,
missing: dayResult.missing_from_ledger.length,
mismatches: dayResult.amount_mismatches.length,
});
current.setDate(current.getDate() + 1);
}
// Summary
var totalMissing = results.reduce(function(sum, r) { return sum + r.missing; }, 0);
var totalMismatches = results.reduce(function(sum, r) { return sum + r.mismatches; }, 0);
return {
days_checked: results.length,
total_missing: totalMissing,
total_mismatches: totalMismatches,
clean: totalMissing === 0 && totalMismatches === 0,
details: results,
};
}
If there are outstanding issues, resolve them before proceeding. The close cannot happen until reconciliation is clean for every day of the month.
Check that every transaction from the month has been included in a settlement and that the settlement has been deposited to your bank or M-Pesa account.
-- Unsettled transactions from the month
SELECT
reference,
gross_amount,
net_amount,
currency,
created_at
FROM payment_ledger
WHERE event_type = 'charge.success'
AND created_at >= '2026-07-01'
AND created_at < '2026-08-01'
AND settled_at IS NULL
ORDER BY created_at;
-- If any rows are returned, these transactions have not been settled.
-- Investigate: are they pending settlement, reversed, or missing?
A small number of unsettled transactions from the last 1-2 days of the month is normal. They should settle in the first few days of the new month. If transactions from mid-month are still unsettled, something is wrong.
-- Monthly refund and chargeback summary
SELECT
event_type,
currency,
COUNT(*) as count,
SUM(gross_amount) as total_amount
FROM payment_ledger
WHERE event_type IN ('refund.full', 'refund.partial', 'chargeback.created', 'chargeback.reversed')
AND created_at >= '2026-07-01'
AND created_at < '2026-08-01'
GROUP BY event_type, currency
ORDER BY event_type;
Review each refund and chargeback. Confirm they are recorded correctly in the ledger. Check that the order statuses match (orders with refund ledger entries should have "refunded" or "partially_refunded" status). For chargebacks, verify the dispute status and outcome.
For detailed refund accounting, see the refund accounting guide. For chargeback details, see the chargeback accounting guide.
-- Monthly fee summary by channel and currency
SELECT
channel,
currency,
COUNT(*) as transaction_count,
SUM(gross_amount) as total_gross,
SUM(fee_amount) as total_fees,
SUM(net_amount) as total_net,
ROUND(SUM(fee_amount)::numeric * 100 / NULLIF(SUM(gross_amount), 0), 2) as effective_rate
FROM payment_ledger
WHERE event_type = 'charge.success'
AND created_at >= '2026-07-01'
AND created_at < '2026-08-01'
GROUP BY channel, currency
ORDER BY total_gross DESC;
Compare the total fees against what you expect based on your Paystack fee agreement. If the effective rate looks different from what you agreed to, investigate. It might be due to fee caps hitting on large transactions, a change in the fee structure, or a recording error.
For the detailed treatment of fees in your ledger, see the gross vs net recording guide.
Log into the Paystack dashboard and compare key numbers against your ledger:
// Generate cross-check report
async function crossCheckReport(year, month) {
var from = year + '-' + String(month).padStart(2, '0') + '-01';
var to = year + '-' + String(month + 1).padStart(2, '0') + '-01';
var ledgerData = await db.query(
'SELECT currency, '
+ 'COUNT(*) FILTER (WHERE event_type = 'charge.success') as charges, '
+ 'SUM(gross_amount) FILTER (WHERE event_type = 'charge.success') as gross, '
+ 'SUM(fee_amount) FILTER (WHERE event_type = 'charge.success') as fees, '
+ 'SUM(gross_amount) FILTER (WHERE event_type LIKE 'refund%') as refunds '
+ 'FROM payment_ledger '
+ 'WHERE created_at >= $1 AND created_at < $2 '
+ 'GROUP BY currency',
[from, to]
);
var report = 'MONTH-END CROSS-CHECK: ' + from + '
';
report += 'Compare these numbers against the Paystack dashboard:
';
for (var i = 0; i < ledgerData.rows.length; i++) {
var row = ledgerData.rows[i];
report += row.currency + ':
';
report += ' Charges: ' + row.charges + '
';
report += ' Gross Revenue: ' + formatCurrency(parseInt(row.gross), row.currency) + '
';
report += ' Fees: ' + formatCurrency(parseInt(row.fees), row.currency) + '
';
report += ' Refunds: ' + formatCurrency(parseInt(row.refunds || 0), row.currency) + '
';
}
return report;
}
Small differences (1-2 transactions) may be due to timezone differences between your system and Paystack. A transaction that happened at 23:55 UTC on July 31 might be recorded as August 1 in one system and July 31 in the other. As long as you can explain the difference, it is acceptable.
Generate the four key reports for the month:
For detailed report generation, see the financial reports guide.
After the close is complete, lock the period. This prevents anyone from accidentally (or intentionally) making changes to the closed month's data that would invalidate your reports.
// Lock a monthly period
async function lockPeriod(year, month) {
await db.query(
'INSERT INTO period_locks (period_year, period_month, locked_at, locked_by) '
+ 'VALUES ($1, $2, NOW(), $3)',
[year, month, 'month_end_close']
);
console.log('Period locked: ' + year + '-' + String(month).padStart(2, '0'));
}
// Check period lock before modifying ledger entries
async function isLedgerWriteAllowed(entryDate) {
var entryMonth = entryDate.getMonth() + 1;
var entryYear = entryDate.getFullYear();
var lock = await db.query(
'SELECT id FROM period_locks WHERE period_year = $1 AND period_month = $2',
[entryYear, entryMonth]
);
if (lock.rows.length > 0) {
return { allowed: false, reason: 'Period is locked' };
}
return { allowed: true };
}
If you need to make a correction to a locked period, add a correction entry in the current (unlocked) period with a note explaining why. This preserves the audit trail. Never unlock a period to modify historical entries.
With the period locked and reports produced, the month-end close is complete. Share the reports with your accountant, update your financial dashboards, and move on to the new month.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course