Bonaventure OgetoBy Bonaventure Ogeto|

Month-End Close for a Business Running on Paystack

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.

What Month-End Close Means

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:

  1. Your payments ledger: Your internal record of every charge, refund, and fee.
  2. The Paystack dashboard/API: Paystack's record of the same transactions.
  3. Your bank/M-Pesa statement: Proof that settlement money actually arrived.

If all three agree, your numbers are solid. If they disagree, you need to investigate and resolve the differences before closing the books.

When to Run the Close

Do not run the close on the first day of the new month. Wait 3-5 business days. Here is why:

  • Transactions from the last day of the month may not be settled until 2-3 business days later.
  • The last settlement of the month may not appear in your bank until early in the new month.
  • Webhooks for transactions at the end of the month might arrive in the first day or two of the new month.

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;
}

Step 1: Final Reconciliation

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.

Step 2: Verify All Settlements Arrived

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.

Step 3: Refund and Chargeback Review

-- 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.

Step 4: Fee Calculation and Verification

-- 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.

Step 5: Cross-Check Against Paystack Dashboard

Log into the Paystack dashboard and compare key numbers against your ledger:

  • Total successful transactions count for the month.
  • Total volume (gross revenue).
  • Total fees.
  • Total refunds.
  • Total settlements.
// 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.

Step 6: Produce Financial Reports

Generate the four key reports for the month:

  1. Gross Revenue Report: Total amount charged to customers, by currency and by product/category if applicable.
  2. Net Revenue Report: Gross revenue minus fees minus refunds minus chargebacks. This is your actual income.
  3. Fee Expense Report: Total Paystack fees for the month, broken down by channel.
  4. Cash Flow Report: Total settlements actually deposited to your bank/M-Pesa, versus total revenue earned. The difference is unsettled balance (money still with Paystack).

For detailed report generation, see the financial reports guide.

Step 7: Lock the Period

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.

Key Takeaways

  • Month-end close is a structured process that confirms your financial numbers for the month are accurate and complete before reporting them.
  • Wait 3-5 business days after month end to start the close. This gives time for the last settlement of the month to arrive in your bank.
  • The close checklist has seven steps: reconciliation, settlement verification, refund and chargeback review, fee calculation, cross-check with Paystack, reporting, and period lock.
  • Produce four key reports: gross revenue, net revenue (after fees), refund summary, and cash received (settlements actually deposited).
  • Differences between revenue earned and cash received are explained by timing (unsettled transactions), fees, and refunds.
  • Lock the period after close to prevent retroactive changes that would invalidate your reports.

Frequently Asked Questions

How long should month-end close take?
If your daily reconciliation has been running cleanly, the close should take 1-2 hours for a small business and a half-day for a larger one. Most of the time is spent investigating any discrepancies found during the process. If reconciliation has not been running, the close can take days.
What if I find a discrepancy during the close that I cannot resolve?
Document the discrepancy with as much detail as possible. If the amount is small and clearly explained (a timing difference, a rounding issue), note it as a known variance and close the month. If the amount is material, escalate to your accountant and Paystack support before closing.
Do I need to do month-end close if I have daily reconciliation?
Yes. Daily reconciliation catches day-to-day issues. Month-end close produces the final numbers for the period, cross-checks them against the Paystack dashboard and your bank statement, and locks the period. They serve different purposes.
What if a refund from July is processed in August?
The refund is recorded in August (when it was processed), not in July (when the original charge happened). This is standard accrual accounting. Your August report will show the refund. Your July report stays unchanged and locked.
Should I use cash basis or accrual basis accounting?
Most businesses should use accrual basis: revenue is recognized when the charge succeeds, not when the settlement arrives. Your accountant can advise on what is appropriate for your business size and jurisdiction. Your ledger supports both approaches since it records both created_at (when the charge happened) and settled_at (when the cash arrived).

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