McTaba Labs logo
Software EngineeringThe WhiteboardProjectsEventsMasterclassesWebinarsLearn
Book Call

500+ developers already in the community

Get answers, find accountability partners, and stay ahead

Join Discord →
McTaba Labs logo

Educating 1 million students in tech by 2030.

Programs

  • HighSchool Graduates Launchpad
  • AI & Business Automation
  • Corporate Training
  • McTaba Story & Mission
  • Full-Stack Curriculum
  • Course Pricing & Plans
  • Frequently Asked Questions

Resources

  • Join Discord Community
  • Tech Events in Nairobi
  • Masterclasses
  • Free Resources
  • Learn
  • Campus
  • Africa Tech Talent Report
  • Partner With Us
  • Start a Project
  • Our Work

Contact Us

  • 124 Manyani East Road, Lavington, Nairobi
  • +254727795603
  • Book a Discovery Call

Office Hours:

Mon-Fri: 9am - 2pm EAT

Sat: 10am - 12pm EAT

© 2027 McTaba Labs - A product of Edtech Industries. All rights reserved.

Privacy PolicyTerms of ServiceRefund Policy
  1. Learn
  2. Paystack for African Developers
  3. Designing a Payments Ledger for a Paystack Integration
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 20, 2026

Designing a Payments Ledger for a Paystack Integration

Design your Paystack payments ledger with separate columns for gross amount, fees, and net amount. Use BIGINT for all money columns (storing values in the smallest currency unit). Add a UNIQUE constraint on (reference, event_type) to prevent duplicate entries. Store the full Paystack response as JSON metadata for audit purposes. Write to the ledger idempotently using ON CONFLICT DO NOTHING.

In this article

  1. 1. Why a Payments Ledger Is Not Optional
  2. 2. The Ledger Schema
  3. 3. Writing to the Ledger
  4. 4. Essential Ledger Queries
  5. 5. How the Ledger Relates to Your Orders Table
  6. 6. Multi-Currency Ledger Considerations
  7. 7. Immutability and How to Handle Corrections
  8. 8. Partitioning and Archiving Old Entries

Why a Payments Ledger Is Not Optional

Most applications start with an orders table. The order has a status column that flips from "pending" to "paid" when the customer pays. This works fine until you need to answer any of these questions:

  • How much did Paystack charge us in fees last month?
  • A customer says they were charged twice. Were they?
  • We received a settlement of NGN 2,340,000 yesterday. Which transactions does that cover?
  • A refund was issued but the customer says they did not receive it. What happened?
  • Our revenue this month is NGN 5M gross but our bank only shows NGN 4.7M. Where is the difference?

An orders table cannot answer these questions because it tracks product fulfillment, not money movement. A payments ledger tracks money movement. Every charge, every fee deduction, every refund, every settlement, every chargeback. Each one is a separate entry with a timestamp, an amount, and a reference back to the Paystack transaction.

For the full verification and reconciliation pipeline that uses this ledger, see the complete verification and reconciliation guide.

The Ledger Schema

Here is a production-ready ledger schema for PostgreSQL:

CREATE TABLE payment_ledger (
  id              BIGSERIAL PRIMARY KEY,
  reference       VARCHAR(100) NOT NULL,
  event_type      VARCHAR(50) NOT NULL,
  -- 'charge.success', 'refund.processed', 'chargeback.created',
  -- 'settlement.processed', 'reversal'

  direction       VARCHAR(10) NOT NULL DEFAULT 'credit',
  -- 'credit' = money in (charges), 'debit' = money out (refunds, chargebacks)

  gross_amount    BIGINT NOT NULL,
  -- Full amount the customer paid (in smallest currency unit)

  fee_amount      BIGINT NOT NULL DEFAULT 0,
  -- Paystack processing fees

  net_amount      BIGINT NOT NULL,
  -- gross_amount - fee_amount (what you actually receive or refund)

  currency        VARCHAR(3) NOT NULL,
  channel         VARCHAR(30),
  -- 'card', 'bank', 'ussd', 'mobile_money', 'bank_transfer'

  paystack_id     BIGINT,
  -- Paystack's internal transaction ID

  customer_email  VARCHAR(255),
  order_id        BIGINT,
  settlement_id   BIGINT,
  -- Populated during settlement reconciliation

  metadata        JSONB DEFAULT '{}',
  -- Full Paystack response payload

  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  settled_at      TIMESTAMPTZ,

  UNIQUE(reference, event_type)
);

-- Indexes for common query patterns
CREATE INDEX idx_ledger_reference ON payment_ledger(reference);
CREATE INDEX idx_ledger_created ON payment_ledger(created_at);
CREATE INDEX idx_ledger_settled ON payment_ledger(settled_at) WHERE settled_at IS NOT NULL;
CREATE INDEX idx_ledger_event_type ON payment_ledger(event_type);
CREATE INDEX idx_ledger_order ON payment_ledger(order_id) WHERE order_id IS NOT NULL;
CREATE INDEX idx_ledger_settlement ON payment_ledger(settlement_id) WHERE settlement_id IS NOT NULL;

Key design decisions in this schema:

  • BIGINT for money. Not FLOAT, not DOUBLE, not NUMERIC. BIGINT stores kobo, pesewas, and cents as exact integers. No rounding errors, no precision problems. A BIGINT holds values up to 9.2 quintillion, which is more than enough.
  • Three money columns. Gross is what the customer paid. Fee is what Paystack took. Net is what you receive. You need all three for different reports.
  • Direction column. Charges are credits (money coming in). Refunds and chargebacks are debits (money going out). This makes balance calculations a simple SUM with a CASE statement.
  • UNIQUE on (reference, event_type). One reference can have multiple events: a charge, then a refund, then a chargeback. But only one charge per reference, one refund per reference. This prevents duplicates while allowing the full lifecycle.
  • JSONB metadata. The full Paystack response, stored as structured JSON. Six months from now, you will want to see the card type, the bank name, the gateway response, the IP address. Store it all now.

Get weekly developer tips

Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.

No spam. Unsubscribe anytime.

Writing to the Ledger

Write to the ledger from your webhook handler, immediately after you verify the transaction. Use ON CONFLICT DO NOTHING to make the insert idempotent:

// Record a charge in the ledger
async function recordCharge(txn, orderId) {
  await db.query(
    'INSERT INTO payment_ledger '
    + '(reference, event_type, direction, gross_amount, fee_amount, '
    + 'net_amount, currency, channel, paystack_id, customer_email, '
    + 'order_id, metadata) '
    + 'VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) '
    + 'ON CONFLICT (reference, event_type) DO NOTHING',
    [
      txn.reference,
      'charge.success',
      'credit',
      txn.amount,
      txn.fees,
      txn.amount - txn.fees,
      txn.currency,
      txn.channel,
      txn.id,
      txn.customer ? txn.customer.email : null,
      orderId,
      JSON.stringify(txn),
    ]
  );
}

// Record a refund in the ledger
async function recordRefund(refundData, originalReference) {
  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,
      0,  // Refund fees depend on your Paystack configuration
      refundData.amount,
      refundData.currency,
      JSON.stringify(refundData),
    ]
  );
}

The ON CONFLICT DO NOTHING clause is critical. Paystack can send the same webhook multiple times. Your sweeper job might re-process the same transaction. With this clause, the second insert silently does nothing instead of throwing an error or creating a duplicate.

Never update a ledger entry. Ledgers are append-only. If a charge is refunded, you add a new refund entry. You do not change the original charge entry. This gives you a complete history of every financial event.

Essential Ledger Queries

Here are the queries you will run most often:

-- Daily revenue summary
SELECT
  currency,
  SUM(CASE WHEN direction = 'credit' THEN net_amount ELSE 0 END) as total_revenue,
  SUM(CASE WHEN direction = 'debit' THEN net_amount ELSE 0 END) as total_refunds,
  SUM(fee_amount) as total_fees,
  COUNT(CASE WHEN event_type = 'charge.success' THEN 1 END) as charge_count,
  COUNT(CASE WHEN event_type = 'refund.processed' THEN 1 END) as refund_count
FROM payment_ledger
WHERE created_at >= '2026-07-01' AND created_at < '2026-07-02'
GROUP BY currency;

-- All events for a specific transaction
SELECT event_type, direction, gross_amount, fee_amount, net_amount,
       created_at, settled_at
FROM payment_ledger
WHERE reference = 'order_abc123_1689012345'
ORDER BY created_at;

-- Unsettled transactions (charges with no settlement date)
SELECT reference, gross_amount, net_amount, currency, created_at
FROM payment_ledger
WHERE event_type = 'charge.success'
  AND settled_at IS NULL
  AND created_at < NOW() - INTERVAL '3 days'
ORDER BY created_at;

-- Net balance by currency (what Paystack owes you)
SELECT
  currency,
  SUM(CASE WHEN direction = 'credit' THEN net_amount
           WHEN direction = 'debit' THEN -net_amount
           ELSE 0 END) as balance
FROM payment_ledger
WHERE settled_at IS NULL
GROUP BY currency;

The unsettled transactions query is particularly useful. If transactions from three or more days ago still have no settled_at date, either your settlement reconciliation is behind, or there is a problem with the settlement itself. For more on this, see the settlement reconciliation guide.

How the Ledger Relates to Your Orders Table

The ledger and the orders table serve different purposes. The orders table tracks what the customer bought and whether it was fulfilled. The ledger tracks money movement. They are linked by the order_id column in the ledger.

-- Orders table (simplified)
CREATE TABLE orders (
  id                  BIGSERIAL PRIMARY KEY,
  user_id             BIGINT NOT NULL,
  product_id          BIGINT NOT NULL,
  status              VARCHAR(20) NOT NULL DEFAULT 'pending',
  paystack_reference  VARCHAR(100) UNIQUE,
  expected_amount     BIGINT NOT NULL,
  expected_currency   VARCHAR(3) NOT NULL,
  created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Joining orders with ledger entries
SELECT
  o.id as order_id,
  o.status as order_status,
  o.product_id,
  l.event_type,
  l.gross_amount,
  l.fee_amount,
  l.net_amount,
  l.settled_at
FROM orders o
LEFT JOIN payment_ledger l ON l.order_id = o.id
WHERE o.id = 123
ORDER BY l.created_at;

When an order has multiple ledger entries (a charge, then a partial refund), the join returns multiple rows. This is correct. Each row represents a separate financial event tied to the same order.

A common mistake is putting fee_amount and net_amount in the orders table instead of the ledger. This works until you have refunds or chargebacks, which create additional financial events that do not fit in a single row on the orders table.

Multi-Currency Ledger Considerations

If your business operates across multiple Paystack-supported countries, your ledger will contain entries in different currencies. NGN, GHS, KES, ZAR, USD. You must never add amounts across currencies. NGN 100,000 plus KES 10,000 is not KES 110,000 or NGN 110,000. It is meaningless.

Always group and aggregate by currency:

-- Monthly revenue by currency (correct)
SELECT
  currency,
  SUM(net_amount) as total_net
FROM payment_ledger
WHERE event_type = 'charge.success'
  AND created_at >= '2026-07-01'
  AND created_at < '2026-08-01'
GROUP BY currency;

-- DO NOT do this:
-- SELECT SUM(net_amount) FROM payment_ledger WHERE ...
-- This adds NGN and GHS together, which is wrong

If you need a single "total revenue" number for management reports, convert each currency to a base currency using a consistent exchange rate. Store the conversion rate used so the calculation is reproducible. See the multi-country currency accounting guide for the full pattern.

Immutability and How to Handle Corrections

Ledger entries should be immutable. Once written, never update or delete them. If you made a mistake (recorded the wrong amount, assigned to the wrong order), add a correction entry rather than changing the original:

// Correcting a ledger entry (add a correction, do not update)
async function correctLedgerEntry(originalReference, correctionReason) {
  // Fetch the original entry
  var original = await db.query(
    'SELECT * FROM payment_ledger WHERE reference = $1 AND event_type = $2',
    [originalReference, 'charge.success']
  );

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

  var entry = original.rows[0];

  // Add a reversal entry
  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)',
    [
      originalReference,
      'correction.reversal',
      entry.direction === 'credit' ? 'debit' : 'credit',
      entry.gross_amount,
      entry.fee_amount,
      entry.net_amount,
      entry.currency,
      JSON.stringify({
        reason: correctionReason,
        original_entry_id: entry.id,
        corrected_at: new Date().toISOString(),
      }),
    ]
  );

  // Add the correct entry
  // (you would pass the correct values here)
}

Immutability means your ledger is an audit trail. Anyone can look at the full history and see exactly what happened, when it happened, and why corrections were made. This is invaluable during financial audits, tax reviews, and chargeback disputes.

For a deeper exploration of this principle, see the double-entry bookkeeping guide.

Partitioning and Archiving Old Entries

A high-volume Paystack integration can generate thousands of ledger entries per day. Over months and years, the table grows large. Two strategies keep it manageable:

Table partitioning. PostgreSQL supports range partitioning on the created_at column. Each month gets its own partition. Queries that filter by date only scan the relevant partitions:

-- Partitioned ledger table
CREATE TABLE payment_ledger (
  id              BIGSERIAL,
  reference       VARCHAR(100) NOT NULL,
  event_type      VARCHAR(50) NOT NULL,
  direction       VARCHAR(10) NOT NULL DEFAULT 'credit',
  gross_amount    BIGINT NOT NULL,
  fee_amount      BIGINT NOT NULL DEFAULT 0,
  net_amount      BIGINT NOT NULL,
  currency        VARCHAR(3) NOT NULL,
  channel         VARCHAR(30),
  paystack_id     BIGINT,
  customer_email  VARCHAR(255),
  order_id        BIGINT,
  settlement_id   BIGINT,
  metadata        JSONB DEFAULT '{}',
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  settled_at      TIMESTAMPTZ,
  UNIQUE(reference, event_type, created_at)
) PARTITION BY RANGE (created_at);

-- Create monthly partitions
CREATE TABLE payment_ledger_2026_07 PARTITION OF payment_ledger
  FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE payment_ledger_2026_08 PARTITION OF payment_ledger
  FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

Archiving. Ledger entries older than your regulatory retention period (typically 7 years for financial records in most African countries) can be exported to cold storage (S3, GCS) and removed from the active table. Keep the exports in a queryable format like Parquet or CSV so you can search them if needed.

Start with a single unpartitioned table. Add partitioning when the table exceeds a few million rows and queries start slowing down. Do not over-engineer on day one.

Key Takeaways

  • ✓A payments ledger records every financial event (charges, refunds, chargebacks, settlements) as an immutable entry. It is your source of truth for money movement.
  • ✓Use BIGINT for all money columns. Store amounts in the smallest currency unit (kobo, pesewas, cents). Never use FLOAT or DECIMAL for payment amounts.
  • ✓Separate gross amount, fees, and net amount into distinct columns. You will need all three for reconciliation and reporting.
  • ✓The UNIQUE constraint on (reference, event_type) prevents duplicate entries when webhooks fire multiple times or your sweeper job re-processes a transaction.
  • ✓Store the full Paystack API response as JSONB metadata. Six months from now, when a customer disputes a charge, this payload is your evidence.
  • ✓Write to the ledger using ON CONFLICT DO NOTHING for idempotent inserts. This makes your webhook handler safe to retry.

Frequently Asked Questions

Should I use the ledger or the orders table to check if a payment was made?
Use the orders table for business logic (is this order paid, can I ship it). Use the ledger for financial questions (how much revenue, how much in fees, what is the refund total). They serve different purposes and should both exist.
Can I use MongoDB instead of PostgreSQL for the ledger?
You can, but PostgreSQL is a better fit for financial data. It supports transactions, strict typing, and the UNIQUE constraint that prevents duplicates. MongoDB can work if you use unique indexes and handle atomicity carefully, but it requires more discipline.
How do I handle partial refunds in the ledger?
Record each partial refund as a separate ledger entry with event_type "refund.processed" and the refund amount. The original charge entry stays unchanged. You can sum all entries for a reference to get the net position (charge minus refunds).
Should I store amounts as integers or use NUMERIC/DECIMAL?
Use BIGINT and store in the smallest currency unit (kobo, pesewas, cents). This avoids all rounding issues. NUMERIC/DECIMAL also works and is technically correct, but BIGINT is simpler, faster, and how Paystack returns amounts.
What if my ledger and Paystack disagree on an amount?
Paystack is the source of truth for what actually happened. If your ledger disagrees, your recording logic has a bug. Investigate by comparing the JSONB metadata (which stores the original Paystack response) against the ledger columns. Fix the bug, then add a correction entry.

Learn Paystack Integration

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

Not ready? Create Free Account

Keep reading

Paystack Payment Verification and Reconciliation: Complete Guide

The complete engineering reference for verifying Paystack transactions server-side, preventing double grants, designing a payments ledger, reconciling settlements against your database, and building automated sweeper jobs that catch missing transactions.

Read more

Double-Entry Bookkeeping for African Payment Integrations

Double-entry bookkeeping is not just for accountants. When you integrate Paystack (or any African payment gateway), recording every transaction as matching debit and credit entries catches errors that single-entry systems miss. This guide shows how to apply the principle in code, with practical PostgreSQL schema and Node.js examples.

Read more

Paystack Fees in Your Ledger: Gross vs Net Recording

When a customer pays NGN 50,000, Paystack takes a processing fee and settles the rest to your bank. How you record this in your ledger determines whether settlement reconciliation is straightforward or a nightmare. This guide covers the gross vs net recording decision and how to implement each approach.

Read more

Reconciling Paystack Settlements Against Your Database

Paystack batches your daily transactions into settlements and deposits the net amount to your bank. Reconciling those settlements against your own records catches missing webhooks, unrecorded refunds, and fee discrepancies before they become accounting nightmares. This guide covers the full reconciliation workflow with code.

Read more

Refund Accounting for Paystack Transactions

Refunds create accounting complexity. A simple order status change is not enough. You need ledger entries that track the original charge, the refund amount, the fee implications, and the settlement impact. This guide covers refund accounting for Paystack integrations with Node.js and SQL.

Read more

Explore related topics

M-Pesa & African Stack

M-Pesa Daraja API tutorials, USSD development, and WhatsApp Business API guides. The African Stack that global bootcamps do not teach.

Learn to Code in Kenya

Everything a Kenyan needs to know before writing their first line of code. Career paths, real costs, hardware requirements, and honest answers about what it takes.

Learning to Code

Roadmaps, language guides, and practical advice for learning to code and launching a programming career.

Breaking Into Tech

Practical, no-nonsense guides for anyone looking to start or change their career in tech. From choosing your first language to acing interviews.

The African Stack: Skills for This Market

The skills that make Kenyan developers hard to replace: M-Pesa, USSD, WhatsApp, fintech, and building for the African market.

Bonaventure Ogeto

Bonaventure Ogeto

Founder, McTaba Labs

Bonaventure is the founder of McTaba Labs, a coding bootcamp in Nairobi, Kenya. He has spent years building products for the African market and mentoring the next generation of developers across the continent.