Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Fees in Your Ledger: Gross vs Net Recording

Record the gross amount (what the customer paid), the fee amount (what Paystack deducted), and the net amount (what you receive) as separate columns in your ledger. This is the "gross recording with fee breakdown" approach. It lets you reconcile against both the customer invoice (gross) and your bank statement (net). Never record only the net amount, as you lose visibility into fee calculations.

The Problem: Which Amount Do You Record?

A customer pays NGN 50,000 for your product. Paystack processes the payment and charges a processing fee. You receive the net amount in your settlement. Your ledger needs to answer three different questions:

  1. How much did the customer pay? The gross amount. You need this for invoices, receipts, and customer-facing records.
  2. How much did we pay in processing fees? The fee amount. You need this for expense tracking and profitability analysis.
  3. How much money actually arrived in our bank? The net amount. You need this for bank reconciliation and cash flow tracking.

If you record only one of these, you cannot answer the other two questions without recalculating from Paystack's fee structure, which may change over time. Store all three at the time of the transaction, and you never need to recalculate.

For the overall ledger design, see the payments ledger design guide.

The Three-Column Approach

Your ledger should have three separate money columns for every entry:

CREATE TABLE payment_ledger (
  id              BIGSERIAL PRIMARY KEY,
  reference       VARCHAR(100) NOT NULL,
  event_type      VARCHAR(50) NOT NULL,
  gross_amount    BIGINT NOT NULL,   -- What the customer paid
  fee_amount      BIGINT NOT NULL DEFAULT 0,   -- What Paystack kept
  net_amount      BIGINT NOT NULL,   -- What we receive
  currency        VARCHAR(3) NOT NULL,
  -- ... other columns
  UNIQUE(reference, event_type)
);

For a charge of NGN 50,000 (5,000,000 kobo) with a Paystack fee of NGN 750 (75,000 kobo):

  • gross_amount = 5,000,000
  • fee_amount = 75,000
  • net_amount = 4,925,000
// Recording with all three columns
async function recordTransaction(txn, orderId) {
  var grossAmount = txn.amount;        // From Paystack: data.amount
  var feeAmount = txn.fees;            // From Paystack: data.fees
  var netAmount = grossAmount - feeAmount;

  await db.query(
    'INSERT INTO payment_ledger '
    + '(reference, event_type, gross_amount, fee_amount, net_amount, '
    + 'currency, channel, paystack_id, order_id, metadata) '
    + 'VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) '
    + 'ON CONFLICT (reference, event_type) DO NOTHING',
    [
      txn.reference, 'charge.success',
      grossAmount, feeAmount, netAmount,
      txn.currency, txn.channel, txn.id, orderId,
      JSON.stringify(txn),
    ]
  );
}

The fee value comes directly from the Paystack verification response (data.fees). Do not calculate it yourself. Paystack's fee structure is complex (different rates for different channels, volumes, and countries). Always read the fee from the API response.

Why Recording Only Net Fails

Some developers record only the net amount because "that is what we actually receive." This creates several problems:

Customer disputes. A customer says "I paid NGN 50,000." Your records show NGN 49,250. The customer thinks you shortchanged them. You have to explain that Paystack took a fee. But without the gross amount in your records, you have to recalculate it, which requires knowing the exact fee rate that applied at the time of the transaction.

Invoicing. When you issue a receipt or invoice, the amount should be what the customer paid (gross), not what you received (net). The fee is your cost of doing business, not something the customer should see on their receipt.

Tax reporting. In many jurisdictions, your revenue for tax purposes is the gross amount. The processing fee is a deductible expense. If you only record the net, your revenue is understated and your expenses are not tracked.

Fee analysis. You cannot track how much you are paying in processing fees over time. You cannot compare fee rates across channels (card vs bank transfer vs USSD) to optimize your checkout flow. You cannot detect if Paystack changed their fee structure.

Why Recording Only Gross Also Fails

Recording only the gross amount creates the opposite problem: settlement reconciliation breaks.

When Paystack deposits NGN 4,925,000 into your bank, and your ledger shows NGN 5,000,000 in charges, there is a NGN 75,000 gap. If you did not record the fee, you have to recalculate it to explain the difference. For a single transaction, that is doable. For a settlement batch of 200 transactions across different channels with different fee rates, it is a headache.

The three-column approach avoids this entirely. Your settlement reconciliation compares the sum of net_amount values against the bank deposit. They should match. If they do not, the difference is immediately visible without any fee recalculation.

-- Settlement reconciliation with three columns is clean
SELECT
  SUM(net_amount) as expected_settlement
FROM payment_ledger
WHERE event_type = 'charge.success'
  AND settled_at IS NULL
  AND created_at >= '2026-07-20'
  AND created_at < '2026-07-21';

-- Compare this against the actual bank deposit
-- If they match, you are done. No fee calculation needed.

Fee Reporting and Analysis

With fees stored in your ledger, you can run powerful analytics:

-- Monthly fee summary by channel
SELECT
  DATE_TRUNC('month', created_at) as month,
  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_fee_rate
FROM payment_ledger
WHERE event_type = 'charge.success'
GROUP BY DATE_TRUNC('month', created_at), channel, currency
ORDER BY month DESC, total_gross DESC;

-- Daily fee total for expense tracking
SELECT
  DATE(created_at) as date,
  currency,
  SUM(fee_amount) as daily_fees
FROM payment_ledger
WHERE event_type = 'charge.success'
  AND created_at >= NOW() - INTERVAL '30 days'
GROUP BY DATE(created_at), currency
ORDER BY date DESC;

The effective_fee_rate calculation shows the actual percentage Paystack charged across all your transactions for each channel. If one channel consistently has a higher effective fee rate, you might want to steer customers toward cheaper channels (if the business case supports it).

Keep in mind that Paystack fee structures can include flat components and percentage components, and may have caps. The effective rate varies by transaction size. Small transactions have a higher effective rate because the flat fee component is a larger proportion of the total.

Fee Caps and Special Cases

Paystack fee structures may include caps (maximum fee per transaction) and special arrangements for high-volume merchants. Your ledger does not need to know the fee structure. It just records what Paystack actually charged, which is in the data.fees field of the verification response.

// Always read the fee from the API, never calculate it
async function getTransactionFee(reference) {
  var response = await fetch(
    'https://api.paystack.co/transaction/verify/'
      + encodeURIComponent(reference),
    {
      headers: {
        Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      },
    }
  );

  var data = await response.json();

  if (data.data) {
    return {
      gross: data.data.amount,
      fee: data.data.fees,
      net: data.data.amount - data.data.fees,
    };
  }

  return null;
}

// BAD: Calculating fees yourself
// var fee = Math.min(amount * 0.015 + 10000, 200000);
// This breaks when Paystack changes their fee structure.

If you need to show estimated fees to customers before they pay (for example, to show "You will be charged NGN 50,000 + processing fee"), use Paystack's fee calculation as an estimate but always record the actual fee from the API response after the transaction completes.

Fee Treatment on Refunds

When you refund a transaction, the fee treatment is an important accounting question. There are two common scenarios:

Scenario 1: Fee not returned. You charged the customer NGN 50,000. Paystack took a fee. You refund the full NGN 50,000. Paystack does not return the fee. Your net loss is the fee amount.

Scenario 2: Fee returned. Some fee agreements return the processing fee on full refunds. Your refund ledger entry should reflect this:

// Refund with fee tracking
async function recordRefundWithFee(originalReference, refundAmount, feeReturned) {
  // Fetch original charge to get the fee
  var original = await db.query(
    'SELECT gross_amount, fee_amount, currency FROM payment_ledger '
    + 'WHERE reference = $1 AND event_type = $2',
    [originalReference, 'charge.success']
  );

  var charge = original.rows[0];
  var isFullRefund = refundAmount === charge.gross_amount;
  var returnedFee = (isFullRefund && feeReturned) ? charge.fee_amount : 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,
      isFullRefund ? 'refund.full' : 'refund.partial',
      'debit',
      refundAmount,
      returnedFee,
      refundAmount - returnedFee,
      charge.currency,
      JSON.stringify({ fee_returned: feeReturned, returned_fee_amount: returnedFee }),
    ]
  );
}

The fee_amount on the refund entry represents the fee that was returned to you, not the fee that was charged on the original transaction. This matters for settlement reconciliation: if the fee is returned, the settlement deduction is the refund amount minus the returned fee. If the fee is not returned, the deduction is the full refund amount.

Passing Fees to the Customer

Some businesses pass the Paystack processing fee to the customer. The customer pays the product price plus the processing fee. This is a business decision with accounting implications.

// If you pass fees to the customer
// Product price: NGN 50,000 (5,000,000 kobo)
// Estimated fee: NGN 750 (75,000 kobo)
// Total charged: NGN 50,750 (5,075,000 kobo)

// In your ledger, the gross amount is 5,075,000 (what the customer paid)
// The fee is 75,000 (what Paystack took)
// The net is 5,000,000 (what you receive, which is your product price)

// Your revenue is the product price, not the gross amount
// The fee is not your expense in this model; it is a pass-through

This changes how you report revenue. If the customer pays the fee, your revenue is the product price (net amount), not the gross amount. Your fee expense is zero because the customer covered it. Record this clearly in your ledger metadata so your finance team knows the fee model.

Be aware that in some jurisdictions, passing fees to customers may have regulatory implications. Check local regulations before implementing this model. For the full picture, see the verification and reconciliation guide.

Key Takeaways

  • Always record three values per transaction: gross amount (what the customer paid), fee amount (what Paystack kept), and net amount (what you receive).
  • The gross amount is what appears on the customer invoice and in the Paystack verification response as data.amount.
  • The fee amount comes from data.fees in the verification response. It is what Paystack charges for processing.
  • The net amount is gross minus fees. It is what Paystack settles to your bank account.
  • Recording only the net amount makes it impossible to tell customers what they paid. Recording only the gross makes settlement reconciliation impossible.
  • Fee amounts should not be hardcoded in your system. Always read them from the Paystack API response, as fee structures can change.

Frequently Asked Questions

Should I store the fee percentage or the fee amount?
Store the fee amount in the smallest currency unit, exactly as Paystack returns it. Do not store a percentage. The actual fee depends on complex rules (flat + percentage, caps, channel-specific rates) that change over time. The amount is what actually happened.
What if the fee in my ledger does not match what Paystack deducted from the settlement?
This is a reconciliation issue. The fee in data.fees from the verification response should match what Paystack deducts. If there is a discrepancy, it could indicate a rounding difference, a fee structure change, or an additional fee (like a chargeback fee) that was applied separately.
Do Paystack fees vary by payment channel?
Yes. Card, bank transfer, USSD, and mobile money typically have different fee structures. Your ledger captures the actual fee per transaction, so you can analyze effective rates per channel without knowing the fee rules.
How do I estimate fees before the transaction for pricing purposes?
Check the current Paystack pricing page for your country and plan. Use those rates as estimates in your pricing model. But always record the actual fee from the API response, as the actual fee may differ from your estimate due to caps, special rates, or rate changes.
Can I negotiate lower Paystack fees?
High-volume merchants may be able to negotiate custom fee structures with Paystack. Contact Paystack business team if you process significant volume. Your fee analysis from the ledger gives you the data to support your negotiation.

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