Bonaventure OgetoBy Bonaventure Ogeto|

Refund Accounting for Paystack Transactions

When processing a Paystack refund, create a separate ledger entry with event_type "refund.processed" that records the refund amount as a debit. Do not modify the original charge entry. Track whether Paystack returns the processing fee on full refunds (behavior varies). Handle the refund.processed webhook to update your ledger automatically. For partial refunds, record each partial refund as a separate entry.

How Paystack Refunds Work

You can issue a refund through the Paystack dashboard or through the API. The API endpoint is POST /refund. You provide the transaction reference or ID and the amount to refund. For a full refund, you provide the full transaction amount. For a partial refund, you provide a smaller amount.

// Issue a refund via the Paystack API
async function issueRefund(transactionReference, amount, reason) {
  var response = await fetch('https://api.paystack.co/refund', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      transaction: transactionReference,
      amount: amount,  // In smallest currency unit. Omit for full refund.
      merchant_note: reason,
    }),
  });

  var data = await response.json();

  if (!data.status) {
    throw new Error('Refund failed: ' + data.message);
  }

  return data.data;
}

Paystack processes the refund and sends a refund.processed webhook when it is complete. The refund amount is typically deducted from your next settlement rather than being charged to your bank account directly.

For the broader accounting system, see the payments ledger design guide.

Recording Refunds in the Ledger

A refund is a new financial event. It gets its own ledger entry. You never modify the original charge entry because the charge did happen. The customer did pay. The refund is a separate event that reverses part or all of that charge.

// Record a refund in the payments ledger
async function recordRefund(refundData, originalReference) {
  // Look up the original charge
  var original = await db.query(
    'SELECT gross_amount, fee_amount, currency, order_id FROM payment_ledger '
    + 'WHERE reference = $1 AND event_type = $2',
    [originalReference, 'charge.success']
  );

  if (original.rows.length === 0) {
    console.error('Cannot record refund: original charge not found for '
      + originalReference);
    return { recorded: false };
  }

  var charge = original.rows[0];

  // Check total refunded so far
  var previousRefunds = await db.query(
    'SELECT COALESCE(SUM(gross_amount), 0) as total_refunded FROM payment_ledger '
    + 'WHERE reference = $1 AND event_type LIKE $2',
    [originalReference, 'refund%']
  );

  var totalRefunded = parseInt(previousRefunds.rows[0].total_refunded);
  var newTotal = totalRefunded + refundData.amount;

  if (newTotal > charge.gross_amount) {
    console.error('Refund would exceed original charge: charge='
      + charge.gross_amount + ' already_refunded='
      + totalRefunded + ' new_refund=' + refundData.amount);
    return { recorded: false, reason: 'exceeds_charge' };
  }

  // Record the refund entry
  var refundEventType = newTotal === charge.gross_amount
    ? 'refund.full' : 'refund.partial';

  await db.query(
    'INSERT INTO payment_ledger '
    + '(reference, event_type, direction, gross_amount, fee_amount, '
    + 'net_amount, currency, order_id, metadata) '
    + 'VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) '
    + 'ON CONFLICT (reference, event_type) DO NOTHING',
    [
      originalReference,
      refundEventType,
      'debit',
      refundData.amount,
      0,  // Fee handling depends on Paystack policy
      refundData.amount,
      charge.currency,
      charge.order_id,
      JSON.stringify(refundData),
    ]
  );

  return { recorded: true, event_type: refundEventType, total_refunded: newTotal };
}

Note the direction is "debit" for refunds. In the original charge, money came in (credit). In a refund, money goes out (debit). This distinction matters for balance calculations and financial reports.

Handling Partial Refunds

A customer buys three items for NGN 30,000. They want to return one item worth NGN 10,000. You issue a partial refund of NGN 10,000 (1,000,000 kobo). The ledger now has two entries for this reference:

-- Ledger state after partial refund
-- Reference: order_abc_123
-- Entry 1: charge.success,  credit, 3000000 kobo (NGN 30,000)
-- Entry 2: refund.partial,  debit,  1000000 kobo (NGN 10,000)
-- Net position: 2000000 kobo (NGN 20,000)

-- Query net position for a reference
SELECT
  reference,
  SUM(CASE WHEN direction = 'credit' THEN gross_amount ELSE 0 END) as total_charged,
  SUM(CASE WHEN direction = 'debit' THEN gross_amount ELSE 0 END) as total_refunded,
  SUM(CASE WHEN direction = 'credit' THEN gross_amount ELSE -gross_amount END) as net_position
FROM payment_ledger
WHERE reference = 'order_abc_123'
GROUP BY reference;

If the customer later returns the second item (another NGN 10,000 refund), you add a third ledger entry. The UNIQUE constraint on (reference, event_type) would prevent this since both are "refund.partial". To handle multiple partial refunds, either use a more specific event type (include a counter or the refund ID) or change the UNIQUE constraint:

// Record multiple partial refunds
async function recordPartialRefund(originalReference, refundData) {
  // Use the Paystack refund ID to make the event unique
  var eventType = 'refund.partial.' + refundData.id;

  await db.query(
    'INSERT INTO payment_ledger '
    + '(reference, event_type, direction, gross_amount, fee_amount, '
    + 'net_amount, currency, metadata) '
    + 'VALUES ($1, $2, $3, $4, $5, $6, $7, $8) '
    + 'ON CONFLICT (reference, event_type) DO NOTHING',
    [
      originalReference,
      eventType,
      'debit',
      refundData.amount,
      0,
      refundData.amount,
      refundData.currency,
      JSON.stringify(refundData),
    ]
  );
}

Processing the Refund Webhook

Paystack sends a refund.processed webhook when a refund is complete. Handle it in your webhook router:

// Webhook handler for refunds
app.post('/webhooks/paystack', async function(req, res) {
  // Validate HMAC signature (not shown)
  var event = req.body;

  if (event.event === 'refund.processed') {
    var refundData = event.data;

    // Find the original transaction reference
    var originalReference = refundData.transaction_reference
      || refundData.transaction.reference;

    // Record in ledger
    var result = await recordRefund(refundData, originalReference);

    if (result.recorded) {
      // Update order status if fully refunded
      if (result.event_type === 'refund.full') {
        await db.query(
          'UPDATE orders SET status = $1, refunded_at = NOW() '
          + 'WHERE paystack_reference = $2',
          ['refunded', originalReference]
        );
      } else {
        await db.query(
          'UPDATE orders SET status = $1, partial_refund_total = $2 '
          + 'WHERE paystack_reference = $3',
          ['partially_refunded', result.total_refunded, originalReference]
        );
      }

      console.log('Refund recorded: ' + originalReference
        + ' amount=' + refundData.amount
        + ' type=' + result.event_type);
    }
  }

  return res.sendStatus(200);
});

Always verify that the refund amount does not exceed the original charge. A bug or a malicious webhook could try to record a refund larger than the charge. Your recordRefund function should reject this with a clear error.

Fee Implications of Refunds

When you charge a customer NGN 50,000, Paystack takes a processing fee. When you refund that charge, what happens to the fee? This depends on your Paystack agreement and may vary. Common scenarios:

  • Fee not returned on refund: You charged NGN 50,000. Paystack took a fee. You refund NGN 50,000. You are out the original fee amount. This is the most common behavior.
  • Fee returned on full refund: Some agreements return the processing fee for full refunds. You get back both the charge amount and the fee. Partial refunds typically do not return any fee.

Check your specific Paystack agreement for fee refund policy. Do not assume either way. In your ledger, record the fee treatment explicitly:

// Refund with fee tracking
async function recordRefundWithFees(originalReference, refundData, feeReturned) {
  var feeAmount = feeReturned ? refundData.fee_returned || 0 : 0;

  await db.query(
    'INSERT INTO payment_ledger '
    + '(reference, event_type, direction, gross_amount, fee_amount, '
    + 'net_amount, currency, metadata) '
    + 'VALUES ($1, $2, $3, $4, $5, $6, $7, $8) '
    + 'ON CONFLICT (reference, event_type) DO NOTHING',
    [
      originalReference,
      'refund.processed',
      'debit',
      refundData.amount,
      feeAmount,
      refundData.amount - feeAmount,
      refundData.currency,
      JSON.stringify({
        refund: refundData,
        fee_returned: feeReturned,
        fee_amount: feeAmount,
      }),
    ]
  );
}

For a deeper treatment of fee recording, see the gross vs net recording guide.

How Refunds Affect Settlements

Paystack deducts refund amounts from future settlements. If you issued a NGN 50,000 refund today, your next settlement will be reduced by NGN 50,000. This means:

  • Your settlement total will not match the simple sum of today's successful charges.
  • Your settlement reconciliation must account for refund deductions.
  • If refunds exceed your daily revenue, the settlement could be negative (Paystack owes you less than the refunds). In extreme cases, Paystack may debit your bank account.
// Settlement reconciliation with refund awareness
async function reconcileWithRefunds(settlementDate) {
  // Fetch settlement data from Paystack
  var settlement = await fetchSettlement(settlementDate);

  // Expected settlement = sum of charges - sum of refunds - sum of fees
  var charges = await db.query(
    'SELECT COALESCE(SUM(net_amount), 0) as total FROM payment_ledger '
    + 'WHERE event_type = $1 AND DATE(created_at) = $2',
    ['charge.success', settlementDate]
  );

  var refunds = await db.query(
    'SELECT COALESCE(SUM(gross_amount), 0) as total FROM payment_ledger '
    + 'WHERE event_type LIKE $1 AND DATE(created_at) = $2',
    ['refund%', settlementDate]
  );

  var expectedSettlement = parseInt(charges.rows[0].total)
    - parseInt(refunds.rows[0].total);

  var actualSettlement = settlement.total_amount;
  var difference = actualSettlement - expectedSettlement;

  if (Math.abs(difference) > 100) {  // Allow small rounding tolerance
    console.error('Settlement mismatch: expected=' + expectedSettlement
      + ' actual=' + actualSettlement + ' diff=' + difference);
  }

  return {
    expected: expectedSettlement,
    actual: actualSettlement,
    difference: difference,
    charges_total: parseInt(charges.rows[0].total),
    refunds_total: parseInt(refunds.rows[0].total),
  };
}

Refund Reporting

Track refund metrics for business visibility:

-- Monthly refund summary
SELECT
  DATE_TRUNC('month', created_at) as month,
  currency,
  COUNT(*) as refund_count,
  SUM(gross_amount) as total_refunded,
  (SELECT SUM(gross_amount) 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_charged,
  ROUND(
    SUM(gross_amount)::numeric * 100 /
    NULLIF((SELECT SUM(gross_amount) 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%'
GROUP BY DATE_TRUNC('month', created_at), currency
ORDER BY month DESC;

A healthy refund rate depends on your industry, but anything above 5-10% warrants investigation. High refund rates can indicate product quality issues, misleading checkout flows, or fraud.

For a comprehensive reporting setup, see the financial reports guide.

Revoking Access After a Refund

When you refund a payment, you typically need to revoke whatever value was granted. If the customer bought a course, revoke access. If they bought credits, deduct the credits. If they bought a physical product that has not shipped, cancel the shipment.

// Revoke access after a full refund
async function revokeAfterRefund(orderId) {
  var order = await db.query(
    'SELECT product_id, user_id, product_type FROM orders WHERE id = $1',
    [orderId]
  );

  if (order.rows.length === 0) return;

  var row = order.rows[0];

  if (row.product_type === 'course') {
    await db.query(
      'UPDATE enrollments SET status = $1, revoked_at = NOW() '
      + 'WHERE user_id = $2 AND course_id = $3',
      ['revoked', row.user_id, row.product_id]
    );
  } else if (row.product_type === 'credits') {
    await db.query(
      'UPDATE user_credits SET balance = balance - '
      + '(SELECT expected_amount FROM orders WHERE id = $1) '
      + 'WHERE user_id = $2',
      [orderId, row.user_id]
    );
  }

  // Log the revocation
  console.log(JSON.stringify({
    event: 'access_revoked',
    order_id: orderId,
    user_id: row.user_id,
    product_id: row.product_id,
    timestamp: new Date().toISOString(),
  }));
}

For partial refunds, the revocation logic depends on your business model. If a customer bought three items and returned one, you revoke access to that one item but keep the other two active. If they bought a subscription and got a partial refund, you might shorten the subscription period rather than revoking it entirely.

Key Takeaways

  • Never modify the original charge entry when a refund is processed. Add a new ledger entry with event_type "refund.processed" and direction "debit".
  • Paystack supports both full and partial refunds via the API. Each partial refund creates a separate ledger entry.
  • Track the total refunded amount per transaction. The sum of all refund entries for a reference must not exceed the original charge amount.
  • Handle the refund.processed webhook to record refunds automatically. Verify the refund details against the original charge.
  • Refund amounts are typically deducted from future settlements. Your settlement reconciliation must account for these deductions.
  • Whether Paystack returns the processing fee on refunds depends on your agreement and the refund type. Do not assume fees are returned.

Frequently Asked Questions

Can I issue multiple partial refunds for the same transaction?
Yes. You can issue as many partial refunds as you want, as long as the total refunded does not exceed the original charge amount. Each partial refund is a separate API call and generates a separate webhook.
How long does a Paystack refund take to reach the customer?
Card refunds typically take 5-10 business days to appear on the customer statement, depending on their bank. Bank transfer refunds may be faster. The refund.processed webhook fires when Paystack initiates the refund, not when the customer receives it.
Should I update the original charge entry or create a new refund entry?
Always create a new refund entry. Never modify the original charge. The charge happened. It is a historical fact. The refund is a separate event. Keeping both entries gives you a complete audit trail.
What happens if I refund more than the original charge?
The Paystack API will reject the refund request. Your code should also validate this before calling the API. Sum all existing refund entries for the reference and verify that the new refund plus existing refunds does not exceed the original charge.
Do I get the processing fee back when I issue a refund?
This depends on your Paystack agreement and the type of refund. Do not assume fees are returned. Check your agreement or contact Paystack support for your specific policy. Record the fee treatment in your ledger regardless.

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