Bonaventure OgetoBy Bonaventure Ogeto|

Building Financial Reports from Paystack Data

Build financial reports by querying your payments ledger, not the Paystack API. Your ledger is faster, always available, and contains your application context (order IDs, product types, customer segments). Key reports include: gross revenue by period, net revenue after fees, refund rate, fee expense breakdown, channel performance, and settlement tracking. Run these as automated queries with results emailed to stakeholders.

Why Reports Should Come from Your Ledger

You could build reports by hitting the Paystack API every time someone asks "how much did we make last month?" But that is slow, rate-limited, and lacks your business context. Your payments ledger already has everything you need: amounts, fees, currencies, channels, timestamps, and order IDs.

The ledger is your single source of truth for financial data. If it is accurate (which your daily reconciliation job confirms), reports built on it are accurate too. No API calls needed, no rate limits to worry about, and queries complete in milliseconds instead of seconds.

This guide assumes you have the ledger schema from the payments ledger design guide with columns for gross_amount, fee_amount, net_amount, currency, channel, event_type, and created_at.

Revenue Summary Report

-- Monthly revenue summary
SELECT
  DATE_TRUNC('month', created_at) as month,
  currency,
  COUNT(*) as transaction_count,
  SUM(gross_amount) as gross_revenue,
  SUM(fee_amount) as total_fees,
  SUM(net_amount) as net_revenue,
  ROUND(AVG(gross_amount)) as avg_transaction_size,
  MAX(gross_amount) as largest_transaction
FROM payment_ledger
WHERE event_type = 'charge.success'
  AND created_at >= '2026-01-01'
GROUP BY DATE_TRUNC('month', created_at), currency
ORDER BY month DESC, currency;
// Generate and format the revenue report
async function generateRevenueReport(fromDate, toDate) {
  var result = await db.query(
    'SELECT DATE_TRUNC('month', created_at) as month, currency, '
    + 'COUNT(*) as count, SUM(gross_amount) as gross, '
    + 'SUM(fee_amount) as fees, SUM(net_amount) as net '
    + 'FROM payment_ledger WHERE event_type = $1 '
    + 'AND created_at >= $2 AND created_at < $3 '
    + 'GROUP BY DATE_TRUNC('month', created_at), currency '
    + 'ORDER BY month DESC',
    ['charge.success', fromDate, toDate]
  );

  // Format for display
  var report = 'REVENUE REPORT: ' + fromDate + ' to ' + toDate + '

';

  for (var i = 0; i < result.rows.length; i++) {
    var row = result.rows[i];
    report += row.month.toISOString().substring(0, 7) + ' (' + row.currency + '):
';
    report += '  Transactions: ' + row.count + '
';
    report += '  Gross Revenue: ' + formatCurrency(parseInt(row.gross), row.currency) + '
';
    report += '  Fees: ' + formatCurrency(parseInt(row.fees), row.currency) + '
';
    report += '  Net Revenue: ' + formatCurrency(parseInt(row.net), row.currency) + '

';
  }

  return report;
}

Channel Performance Report

Understand which payment channels drive the most revenue and which cost the most in fees:

-- Channel performance for the current month
SELECT
  channel,
  currency,
  COUNT(*) as transaction_count,
  SUM(gross_amount) as total_volume,
  SUM(fee_amount) as total_fees,
  ROUND(SUM(fee_amount)::numeric * 100 / NULLIF(SUM(gross_amount), 0), 2) as effective_fee_rate,
  ROUND(AVG(gross_amount)) as avg_transaction_size,
  ROUND(COUNT(*)::numeric * 100 / SUM(COUNT(*)) OVER (PARTITION BY currency), 1) as pct_of_transactions
FROM payment_ledger
WHERE event_type = 'charge.success'
  AND created_at >= DATE_TRUNC('month', NOW())
GROUP BY channel, currency
ORDER BY total_volume DESC;

This report answers questions like: "What percentage of our revenue comes from card vs bank transfer?" and "Is USSD cost-effective given its fee rate and transaction volume?" If bank transfers have a lower fee rate but much smaller average transaction size, the overall fee savings might be negligible.

Refund Analysis Report

-- Refund analysis
SELECT
  DATE_TRUNC('month', created_at) as month,
  currency,
  COUNT(*) as refund_count,
  SUM(gross_amount) as total_refunded,
  (SELECT COUNT(*) FROM payment_ledger pl2
   WHERE pl2.event_type = 'charge.success'
   AND DATE_TRUNC('month', pl2.created_at) = DATE_TRUNC('month', pl.created_at)
   AND pl2.currency = pl.currency) as total_charges,
  ROUND(
    COUNT(*)::numeric * 100 /
    NULLIF((SELECT COUNT(*) FROM payment_ledger pl3
     WHERE pl3.event_type = 'charge.success'
     AND DATE_TRUNC('month', pl3.created_at) = DATE_TRUNC('month', pl.created_at)
     AND pl3.currency = pl.currency), 0), 2
  ) as refund_rate_pct
FROM payment_ledger pl
WHERE event_type LIKE 'refund%'
  AND created_at >= '2026-01-01'
GROUP BY DATE_TRUNC('month', created_at), currency
ORDER BY month DESC;

A rising refund rate needs investigation. Is it a product quality issue? A checkout UX problem where customers buy by mistake? A specific product or customer segment driving the refunds? Break down refunds by product or customer segment to find the root cause.

For the detailed refund accounting treatment, see the refund accounting guide.

Cash Flow and Settlement Tracking

The gap between "money earned" and "money in the bank" is the settlement delay. Track it explicitly:

-- Unsettled balance (money Paystack owes you)
SELECT
  currency,
  SUM(net_amount) as unsettled_amount,
  COUNT(*) as unsettled_count,
  MIN(created_at) as oldest_unsettled
FROM payment_ledger
WHERE event_type = 'charge.success'
  AND settled_at IS NULL
GROUP BY currency;

-- Settlement timeline (how long it takes to receive money)
SELECT
  currency,
  ROUND(AVG(EXTRACT(EPOCH FROM (settled_at - created_at)) / 86400), 1) as avg_days_to_settle,
  ROUND(MAX(EXTRACT(EPOCH FROM (settled_at - created_at)) / 86400), 1) as max_days_to_settle,
  COUNT(*) as sample_size
FROM payment_ledger
WHERE event_type = 'charge.success'
  AND settled_at IS NOT NULL
  AND created_at >= NOW() - INTERVAL '90 days'
GROUP BY currency;

The "unsettled balance" report shows how much cash is in transit between Paystack and your bank. If this number grows unexpectedly, settlements might be delayed. If the "oldest_unsettled" is more than a week old, investigate why that transaction has not been settled.

Automated Daily Summary

Send a daily financial summary to your team. This gives everyone visibility into payment health without logging into any dashboard:

// Daily summary job
async function dailySummary() {
  var yesterday = new Date();
  yesterday.setDate(yesterday.getDate() - 1);
  var targetDate = yesterday.toISOString().split('T')[0];

  var charges = await db.query(
    'SELECT currency, COUNT(*) as count, '
    + 'SUM(gross_amount) as gross, SUM(fee_amount) as fees, '
    + 'SUM(net_amount) as net '
    + 'FROM payment_ledger WHERE event_type = $1 '
    + 'AND DATE(created_at) = $2 GROUP BY currency',
    ['charge.success', targetDate]
  );

  var refunds = await db.query(
    'SELECT currency, COUNT(*) as count, SUM(gross_amount) as total '
    + 'FROM payment_ledger WHERE event_type LIKE $1 '
    + 'AND DATE(created_at) = $2 GROUP BY currency',
    ['refund%', targetDate]
  );

  var pending = await db.query(
    'SELECT COUNT(*) as count FROM orders '
    + 'WHERE status IN ($1, $2) AND created_at < NOW() - INTERVAL '1 hour'',
    ['pending', 'processing']
  );

  var message = 'Daily Payment Summary - ' + targetDate + '

';
  message += 'CHARGES:
';
  for (var i = 0; i < charges.rows.length; i++) {
    var c = charges.rows[i];
    message += '  ' + c.currency + ': ' + c.count + ' transactions, '
      + 'Gross ' + formatCurrency(parseInt(c.gross), c.currency) + ', '
      + 'Fees ' + formatCurrency(parseInt(c.fees), c.currency) + ', '
      + 'Net ' + formatCurrency(parseInt(c.net), c.currency) + '
';
  }

  if (refunds.rows.length > 0) {
    message += '
REFUNDS:
';
    for (var j = 0; j < refunds.rows.length; j++) {
      var r = refunds.rows[j];
      message += '  ' + r.currency + ': ' + r.count + ' refunds, '
        + formatCurrency(parseInt(r.total), r.currency) + '
';
    }
  }

  message += '
PENDING ORDERS: ' + pending.rows[0].count;

  // Send via Slack or email
  await sendDailySummary(message);
}

Month-Over-Month Trends

-- Month-over-month growth
WITH monthly AS (
  SELECT
    DATE_TRUNC('month', created_at) as month,
    currency,
    SUM(net_amount) as revenue
  FROM payment_ledger
  WHERE event_type = 'charge.success'
    AND created_at >= NOW() - INTERVAL '6 months'
  GROUP BY DATE_TRUNC('month', created_at), currency
)
SELECT
  m.month,
  m.currency,
  m.revenue,
  LAG(m.revenue) OVER (PARTITION BY m.currency ORDER BY m.month) as prev_month_revenue,
  CASE
    WHEN LAG(m.revenue) OVER (PARTITION BY m.currency ORDER BY m.month) > 0
    THEN ROUND((m.revenue - LAG(m.revenue) OVER (PARTITION BY m.currency ORDER BY m.month))::numeric
      * 100 / LAG(m.revenue) OVER (PARTITION BY m.currency ORDER BY m.month), 1)
    ELSE NULL
  END as growth_pct
FROM monthly m
ORDER BY m.currency, m.month;

Growth trends matter more than absolute numbers for most audiences. "Revenue grew 15% month-over-month" tells a clearer story than "Revenue was NGN 5,000,000." Include both in your reports.

Exporting Reports

Different stakeholders need reports in different formats:

  • Slack messages: For the daily summary, sent to a #payments channel. Keep it short and scannable.
  • CSV exports: For your accountant who works in Excel. Include raw numbers, not formatted strings.
  • JSON API: For your admin dashboard. Return structured data that the frontend can render as charts and tables.
  • PDF reports: For board meetings and investor updates. Generate from a template with your branding.
// CSV export of monthly data
async function exportMonthlyCsv(year, month) {
  var result = await db.query(
    'SELECT reference, event_type, gross_amount, fee_amount, '
    + 'net_amount, currency, channel, customer_email, created_at '
    + 'FROM payment_ledger '
    + 'WHERE DATE_TRUNC('month', created_at) = $1 '
    + 'ORDER BY created_at',
    [year + '-' + String(month).padStart(2, '0') + '-01']
  );

  var header = 'Reference,Event,Gross,Fees,Net,Currency,Channel,Customer,Date
';
  var rows = result.rows.map(function(r) {
    return [
      r.reference, r.event_type, r.gross_amount, r.fee_amount,
      r.net_amount, r.currency, r.channel, r.customer_email,
      r.created_at.toISOString(),
    ].join(',');
  }).join('
');

  return header + rows;
}

For the data warehouse approach to report building, see the data warehouse export guide.

Key Takeaways

  • Build reports from your payments ledger, not directly from the Paystack API. Your ledger is faster and contains application context.
  • Essential reports: gross revenue, net revenue, fee expense, refund rate, channel breakdown, and unsettled balance.
  • Always group financial data by currency. Never add NGN and KES amounts together.
  • Automate report generation with scheduled queries. Email or Slack the results to stakeholders on a fixed schedule.
  • Track month-over-month trends. A single month's numbers mean little without context. Growth rates, refund rate changes, and fee trends tell the real story.
  • Include reconciliation status in financial reports. If reconciliation has not run or has open issues, the report should say so.

Frequently Asked Questions

How often should financial reports be generated?
Daily summaries should be automated (Slack or email every morning). Weekly reports with trend analysis work well for management. Monthly reports with full breakdowns are standard for accounting. Quarterly and annual reports are for investors and tax purposes.
Should I use the Paystack dashboard reports or my own?
Use both. The Paystack dashboard is good for quick checks and verifying that your data matches. Your own reports add business context (product breakdown, customer segments) that Paystack does not have. If your reports and the Paystack dashboard disagree, investigate the discrepancy.
What if my report data does not match the Paystack dashboard?
Run your reconciliation job first. The most common cause is missing ledger entries (lost webhooks). Once reconciliation is clean, the numbers should match. Small differences may be due to timing (a transaction that happened at midnight might be counted in different days by different systems).
How do I report revenue for tax purposes?
Consult your accountant for your specific jurisdiction. Generally, revenue for tax purposes is the gross amount (what the customer paid). Processing fees are a deductible business expense. Your ledger provides both numbers separately, which is exactly what your accountant needs.
Can I use BI tools like Metabase or Grafana with my ledger?
Yes. Connect your BI tool directly to your PostgreSQL database and point it at the payment_ledger table. You get interactive dashboards with filtering, drill-down, and visualization without writing any code. This is the easiest path to visual reports.

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