Payment Router · Configure & Simulate
Companion to DOC · the handbook

Configure
the providers.
Watch it learn.

The hands-on half DOC leaves out: how to add providers, how amount limits and the business multiplier decide who's eligible, the three ways to pull a provider out of rotation, and the CLI simulator walkthrough the doc folder confusingly files under "testing."

01

Configure your providers

Everything routing depends on starts here. Each provider is a row in the SQLite provider_config table, created and edited from /admin/providers.

Before the simulator below means anything, you need providers. Walk through it once:

  1. Start the services

    terminaldocker compose up -d dragonfly
    bun run dev   # wait for "Server running on port 3000"
  2. Open the admin dashboard

    Go to http://localhost:3000/admin and sign in with Google. Then click Providers in the sidebar (/admin/providers).

  3. Add three providers

    Click Add Provider and fill in each. These are the values the rest of this doc and the simulator assume:

    name type min max currencies mult
    stripe card 100 1,000,000 NGN, USD 1.0
    paystack card 100 500,000 NGN 1.0
    flutterwave card 500 2,000,000 NGN, USD, GBP 1.0
  4. Verify

    terminal$ curl http://localhost:3000/api/health
    {"status":"healthy","providers":["stripe","paystack","flutterwave"]}

The provider config model

ProviderConfiginterface ProviderConfig {
  name: string;                 // unique id
  enabled: boolean;             // active without deleting
  type: string;                 // "card", "bank_transfer", …
  currencies: string[];         // ["NGN", "USD"]
  amountLimits: {               // per-currency min/max
    [currency: string]: { min: number; max?: number };
  };
  businessMultiplier: number;   // 0–10, default 1.0
  priority: number;             // default 0
}

Try it: who's eligible, and at what share?

This is the eligibility filter and weight blend, live. Move the amount, switch the currency, drag the business multipliers — the bars show which providers can take the transaction and how the router would split traffic among them.

Fig. 1 · interactive
Eligibility filter + business-multiplier weighting
3 of 3 eligible
₦5,000
₦0₦500k₦1M₦1.5M₦2M

Base weights are illustrative (stripe 0.72 · paystack 0.65 · flutterwave 0.45, from the WIKI's "after 50 transactions" example). Ineligible providers drop to 0% and the rest renormalize.

02

Amount limits & the business multiplier

Two knobs decide who can take a transaction (amount limits) and how much of the eligible traffic they get (business multiplier).

Amount limits = a hard gate

Eligibility is a simple filter applied before any weighting — fall outside a provider's range and it's skipped entirely:

eligibility filterif (amount < provider.minAmount) continue;            // too small
if (provider.maxAmount && amount > provider.maxAmount) continue; // too big

With the three providers from chapter 01, that produces:

amount eligible providers why
₦50 none below every minimum
₦1,000 all three within every range
₦750,000 stripe, flutterwave exceeds paystack's 500k max
₦1,500,000 flutterwave only one reaches to 2M

Use it to match provider capability to transaction size — fee structures, per-provider caps, and so on. (Scrub the amount slider in the chapter 01 simulator to watch these exact transitions.)

Business multiplier = a soft thumb on the scale

Once a provider is eligible, the multiplier scales its computed weight:

weightingfinalWeight = baseWeight * businessMultiplier;
value effect
1.0 normal weight (default)
2.0 double the traffic allocation
0.5 half the traffic allocation

Reach for it to:

  • Favor providers with better commercial terms.
  • Gradually shift traffic during a migration.
  • A/B test provider performance at different traffic levels.
Limits gate, multiplier nudges

The multiplier never makes an ineligible provider eligible — the amount filter runs first and is absolute. Multiplier only redistributes traffic among the providers that already passed the gate. The chapter 01 simulator shows both at once: the eligibility badge is the gate, the multiplier slider is the nudge.

03

Three ways to stop traffic

Open Circuit, Disable, and Delete look similar in the UI but differ in duration and in what they keep. Picking the wrong one is how you lose a provider's metrics by accident.

action where duration keeps config + metrics?
Open Circuit dashboard / API temporary — auto-recovers yes
Disable Providers page until re-enabled yes
Delete Providers page permanent no — gone
How long do you need it stopped? Open Circuit minutes · auto-recovers Disable indefinite · keeps state Delete forever · destroys state brief blip maintenance never coming back
Fig. 2 The decision is duration-driven. When unsure between Disable and Delete, choose Disable — it's reversible and preserves the provider's accumulated weight and metrics for when you re-enable it.

Open Circuit — a quick, temporary blip

For a provider having live issues. It auto-recovers through half-open back to closed. Drive it from a dashboard card or the API:

curl$ curl -X POST http://localhost:3000/api/providers/stripe/circuit/open \
   -d '{"reason":"provider degraded"}'
$ curl -X POST http://localhost:3000/api/providers/stripe/circuit/close

Disable — longer-term, reversible

For maintenance or a contract pause. The provider stays in the database with all config and metrics; re-enable any time and it resumes with its existing weight. This is the safe default for "stop, but I'll want it back."

Delete — permanent

Removes the provider and everything about it. Use with caution; there's a confirmation dialog for a reason.

04

The CLI simulator walkthrough

This is what the doc folder's vi.TESTING.md actually contains — not a test-suite guide, but a hands-on walkthrough for driving synthetic traffic at your real router and watching the weights move.

Naming note

In the source docs this material is filed under "testing," which is misleading: it's a simulator walkthrough, not the unit-test suite. (For the actual 67-test suite, see DOC chapter 05.) This chapter keeps the content but gives it its real name.

Run it

  1. Services up, providers configured

    You did this in chapter 01: docker compose up -d dragonfly, bun run dev, and three providers added.

  2. Start the simulator

    terminalbun run simulate   # defaults: 2 TPS, 120s, verbose
  3. Watch live in the dashboard

    Open http://localhost:3000/admin. Weights, success rates, and circuit states update over SSE; thanks to hx-boost the live data survives page navigation.

Options

flag default what it does
--tps 2 transactions per second
--duration 120 seconds to run; 0 = forever
--verbose off log every transaction
examplesbun run scripts/simulate.ts --tps 10 --duration 60   # high volume
bun run scripts/simulate.ts --tps 5 --duration 0 --verbose
bun run scripts/simulate.ts --tps 20 --duration 30   # quick burst

What each tick does

Every 1000/TPS ms the simulator generates one transaction (random amount ₦100–₦10,000) and runs it through the real API:

per transaction1. GET  /api/route                // ask for a provider
2. simulate the provider response // success/latency/async from mock rates
3. POST /api/metrics/report       // report the outcome
4. (async) confirm via webhook    // after a short delay

The mock provider profiles

Edit scripts/simulate.ts to change how each provider "behaves":

scripts/simulate.tsconst SIMULATED_PROVIDERS = {
  default:     { successRate: 0.85, latencyMs: 500, asyncRate: 0.2 },
  stripe:      { successRate: 0.95, latencyMs: 300, asyncRate: 0.1 },
  paystack:    { successRate: 0.9,  latencyMs: 400, asyncRate: 0.3 },
  flutterwave: { successRate: 0.8,  latencyMs: 600, asyncRate: 0.2 },
};

successRate is the probability of success (0–1), latencyMs the average response time, asyncRate the chance of returning "pending" (needs a webhook).

What you'll see in the terminal

stdout[Simulator] Starting simulation at 5 TPS...
[Tx abc123] Routed to stripe (weight: 0.72) - SUCCESS in 287ms
[Tx def456] Routed to paystack (weight: 0.65) - PENDING (webhook)
[Tx ghi789] Routed to flutterwave (weight: 0.45) - FAILED in 1203ms
[Webhook] def456 confirmed for paystack

Weights converge as data accumulates

after stripe paystack flutterwave
0 tx (priors) 0.60 0.60 0.60
~50 tx 0.72 0.65 0.45
~200 tx 0.78 0.62 0.38

Stripe pulls ahead (best success + lowest latency), but the 5% exploration keeps probing the others so a recovery is never missed.

Three things worth simulating

  • Normal operation--tps 5 --duration 60; watch weights stabilize.
  • Degradation — drop a provider's successRate in the script, restart, watch its weight fall.
  • Circuit breaker — set a successRate to 0, watch the circuit open, then raise it and watch recovery.

Prefer a picture? Use the web simulator

bun run simulator starts a self-contained app on port 3001 with live charts and one-click failure scenarios (baseline, provider failure, high latency, partial outage, recovery). Same router code, mock providers — the visual way to see the algorithm work. The CLI tool, by contrast, drives your real configured providers.

Troubleshooting

  • "No available providers" — add at least one enabled provider at /admin/providers.
  • Simulator won't connect — confirm bun run dev and Dragonfly are up; check /api/health.
  • Weights not moving — run longer or raise --tps; Bayesian learning needs volume.