Lesson 33Risk and limitsIntermediate

Velocity limits and review queues

When a single transaction is too big or too fast, the ledger holds it for human review.

By Solomon Ajayi · Free to read, no signup

Alice attempts a ₦500,000 transfer to Bob. Your platform's velocity threshold for her tier is ₦200,000 per hour. The transfer cannot post normally, it goes into a manual review queue. Your ops team gets paged, reviews the transaction, either approves (releases to Bob) or rejects (returns to Alice). The queue is a real liability account on your books; its depth is a real-time risk metric. This pairs with Lesson 6 (authorization hold), the structure is identical, but the gate is a HUMAN approval instead of a provider response.

Some transfers are too big or too fast to let through on autopilot, but you also should not silently drop them. Velocity limits catch these, and the right response is to suspend the transfer in a defined state rather than reject it outright. When Alice's ₦500,000 trips her hourly limit, the money leaves her wallet but does not reach Bob; it lands in a Pending Manual Review liability and waits for a human.

Structurally this is the authorization hold from Lesson 6, with one swap: the gate that releases the funds is a person, not a provider callback. Debiting Alice's wallet immediately matters, because it stops her from double-spending the same money while the review is pending; the credit to the review queue records that the funds are in limbo, not gone. Approval mirrors the release, crediting Bob's wallet; rejection is the same shape pointed the other way, crediting the money back to Alice.

Because the queue is a real liability account, its balance is a live risk signal, not just an app feature. The total sitting in Pending Manual Review is the depth of your review backlog: rising depth means your thresholds are too tight, you are under attack, or ops is understaffed. And every hold carries its trail, who triggered it, who approved it, and when, which is precisely the audit evidence a regulator asks for.

Worked example, step by step

Set the stage: Alice has ₦600,000 in her wallet

Alice's existing balance is ₦600,000. Bob has ₦0. The FBO holds matching ₦600,000 at the sponsor bank.

Alice existing balance ₦600,000
AccountDebitCredit
FBO at Sponsor Bank (1300)₦600,000.00
Alice Wallet (2000)₦600,000.00

Standard funding state. Same shape as Lesson 30's FBO pattern.

Alice initiates ₦500,000 transfer to Bob, held for review

Alice clicks Send. Your velocity rules trip (₦500,000 in one transaction exceeds the ₦200,000/hour limit). The funds leave Alice's wallet but do NOT reach Bob. They sit in 'Pending Manual Review' until your ops team approves.

Hold ₦500,000 for manual review
AccountDebitCredit
Alice Wallet (2000)₦500,000.00
Pending Manual Review (2200)₦500,000.00

Alice Wallet DOWN ₦500,000 (her available balance drops, she can't double-spend while review is pending). Pending Manual Review UP ₦500,000 (queue grows). Bob Wallet UNCHANGED (he doesn't know yet). FBO UNCHANGED (no money moved). Same shape as Lesson 6's authorization hold, but the 'release' gate is a human review instead of a provider callback.

The velocity check: sum debits over a moving window.

-- Has this user moved more than NGN200,000 in the last hour?
-- Source of truth is the ledger; no separate "totals" table to drift.
SELECT COALESCE(SUM(jl.debit), 0) AS debited_last_hour
  FROM journal_line jl
  JOIN journal_entry je ON je.id = jl.entry_id
 WHERE jl.account_code = $1                       -- the user's wallet
   AND je.posted_at >= now() - interval '1 hour';

-- Index for the lookup:
CREATE INDEX journal_line_velocity
  ON journal_line (account_code, entry_id)
  INCLUDE (debit);

Gate the route: either post normally or send to the review queue.

import { Client } from "pg";

const VELOCITY_LIMIT_HOURLY = 20_000_000n;  // NGN200,000 in minor units

export async function transfer(
  client: Client,
  args: { fromCode: string; toCode: string; amount: bigint },
): Promise<{ status: "posted" | "held" }> {
  try {
    await client.query("BEGIN");

    const { rows: [row] } = await client.query<{ debited_last_hour: string }>(
      `SELECT COALESCE(SUM(jl.debit), 0)::text AS debited_last_hour
         FROM journal_line jl
         JOIN journal_entry je ON je.id = jl.entry_id
        WHERE jl.account_code = $1
          AND je.posted_at >= now() - interval '1 hour'`,
      [args.fromCode],
    );

    const recent = BigInt(row.debited_last_hour);
    const wouldExceed = recent + args.amount > VELOCITY_LIMIT_HOURLY;

    const destination = wouldExceed ? "2200" : args.toCode;
    const description = wouldExceed
      ? `Hold NGN${args.amount} for manual review`
      : `Transfer NGN${args.amount}`;

    const { rows } = await client.query<{ id: string }>(
      `INSERT INTO journal_entry (description) VALUES ($1) RETURNING id`,
      [description],
    );
    const entryId = rows[0].id;

    await client.query(
      `INSERT INTO journal_line (entry_id, account_code, debit, credit) VALUES
         ($1, $2, $3, 0),
         ($1, $4, 0, $3)`,
      [entryId, args.fromCode, args.amount, destination],
    );

    await client.query("COMMIT");
    return { status: wouldExceed ? "held" : "posted" };
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  }
}

Ops approves after 1 hour, release into Bob's wallet

Your ops team checks Alice's history, finds no red flags, approves. The held funds now flow to Bob.

Approve transfer, release to Bob
AccountDebitCredit
Pending Manual Review (2200)₦500,000.00
Bob Wallet (2001)₦500,000.00

Pending Manual Review DOWN ₦500,000 (queue clears). Bob Wallet UP ₦500,000 (he can now see and spend it). Alice and FBO unchanged from the hold state. This is the APPROVED path. The REJECTED path would credit the held amount back to Alice instead, same shape, different destination.

Takeaway

Velocity-triggered review queues are a liability account on your books, not just an app-layer feature. The queue's DEPTH (sum of all pending-review entries) is a real-time operations metric: rising depth means thresholds are too tight OR you're under attack OR ops is understaffed. Approval and rejection are inverse entries: approve releases to destination, reject returns to sender. The ledger trail (who held it, who approved, when) becomes audit evidence, exactly what regulators want to see.

Practice this on a real ledger

Reading is half of it. Open this lesson in the lab to post the entries yourself against a real Postgres-backed double-entry ledger, with the validation on. Free, your sandbox is yours.

More in this section

Search lessons

Type to find any of the 85 lessons. Press Enter to open.