Bonaventure OgetoBy Bonaventure Ogeto|

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.

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.

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.

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