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.
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:
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.
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,000fee_amount = 75,000net_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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
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.
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.
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.
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.
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.
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