The payment-rail desk and the one-time rail repair

/admin/p2p/payment-rail, the Country Setup console, how railSlug deduplicates names, what promotes a rail into the public market filter, and the one-time repair script.

10 min readUpdated 6 August 2026admin, payment-methods, rails, setup, migration

The Payments group in the P2P admin nav has three screens. This page covers two of them — Payment Rails and Country Setup — plus the one-time script that populates the first from the third. The third screen, Payment Methods, lists the accounts traders have saved and is covered by Payment methods.

All three read view.p2p.payment_method; creating needs create.p2p.payment_method and editing needs edit.p2p.payment_method. There are deliberately no *.p2p.payment_rail keys — a permission key has to be registered in four places and the seeder is the only writer to the permission table, so a key it does not list is ungrantable and would 403 every non-Super-Admin forever, silently. Rails and methods are the same job for the same operator.

Until you grant them in Admin → Roles, every one of these screens refuses everyone except a Super Admin.

A rail is the identity; a payment method is one trader's credentials

This is the whole distinction, and everything below follows from it.

Rail Payment method
Table p2p_payment_rails p2p_payment_methods
Holds the name, the icon, and the fields this way of paying needs one trader's values for those fields
Shared by everyone by nobody — it belongs to its owner
Rows one per way of paying one per trader per way of paying

The market's payment filter is built by walking the methods attached to live offers and grouping them. It used to group on the method row id, and a method row is one trader's account — so a thousand traders accepting PayPal produced a thousand filter entries, every one of them reading "PayPal", and a taker picking one of them narrowed the board to a single maker without being told that is what they had done.

Grouping on the rail is the fix. PayPal is one rail however many people hold an account on it.

/admin/p2p/payment-rail

The list of shared rails. Columns: the name with its match key beneath it, the fields the rail asks for, whether it is in the filter, its origin (catalogue or trader-defined), and its availability.

Create and delete are both switched off, on purpose:

  • A rail is created by the trader who needs it, or by the setup pass. An admin-only create here would be a third way to make one and a third way to make a duplicate.
  • Traders hold accounts on these, offers point at those accounts, and settled trades quote the name. The way to retire a rail is to clear available, which stops it being picked and leaves every existing account intact.

The match key, and why "Pay Pal" is refused

The slug column is the deduplication key, and it is shown in the table rather than hidden — an operator wondering why a rename was refused needs to see that both names reduce to the same thing.

The rule, in backend/src/api/(ext)/p2p/payment-method/rails.ts:

String(name).normalize("NFKD").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "")

NFKD-normalised, lower-cased, and everything that is not a letter or a digit removed. So PayPal, Paypal, pay pal, Pay-Pal and PayPal all reduce to paypal, and the unique index on slug refuses the second one.

It is deliberately aggressive. The gentler alternative — collapse whitespace, lower the case, keep punctuation — leaves pay-pal and pay pal as two different rails, and a filter with the same name in it many times over is the whole complaint this answers. Two genuinely different rails whose names differ only in punctuation is a case worth losing to catch that.

It is unicode-aware: \p{L} rather than a-z, so "Ví MoMo" and "Мир" reduce to something meaningful instead of to nothing. A name of nothing but punctuation yields an empty slug and is refused outright — "The name needs at least one letter or digit."

The same rule is mirrored in the repair script and in the market facet. All three must agree, or a rail one of them creates is one the others will not find.

What the edit form changes

Updates one rail

The route accepts name, description, icon, processingTime, listed, available, popularityRank and fields.

The form on the screen offers a subset: name, icon and processingTime under Basic information, then listed, available and popularityRank under In the filter. There is no fields editor — the shape is an ordered array of objects, a datatable form cannot edit that without a repeater, and getting it wrong silently rewrites what every trader on that rail is asked for. The route accepts a corrected shape, so a control can be added later; a broken one now would be worse than none. slug is not editable either: it is recomputed from the name on every save.

Field-shape validation lives in normaliseFields and refuses, with a sentence written for a person: a label over 60 characters, a duplicate label, more than 12 fields, or one of the reserved labels — name, icon, instructions, processingTime. Those four are reserved because the trade's payment snapshot is built as { name, icon, instructions, processingTime, ...metadata }, so a field stored under one of those keys does not sit beside the method's own name in the trade room, it replaces it.

listed is the promotion switch

listed is what puts a rail into the public marketplace filter. Catalogue rails ship listed; a trader-defined rail starts unlisted.

An unlisted rail is not disabled. It works from the moment it is saved, it is findable by name by the next trader who needs it, and it appears on the offers that use it. What it does not do is enter the filter every visitor sees on the strength of one person having typed it.

An unlisted rail enters the public filter once it is carried by offers from two or more distinct makers (UNLISTED_RAIL_MIN_MAKERS = 2, in backend/src/api/(ext)/p2p/market/method-facet.ts). That is the cheapest available proof that it is a way of paying rather than a name somebody typed.

Setting listed here overrides the threshold and shows it immediately.

A method row with no railId at all — every row created before rails existed — is treated as listed, so the filter is not emptied on an install whose data has not been repaired yet.

A rename is not a merge

Renaming "Kuda" to "Kuda Bank" when both rails exist would be a merge, and this endpoint deliberately does not do merges — the accounts on the two rails hold different people's details, and combining them puts somebody's account number on a rail they did not choose.

The refusal names the other rail and explains the rule:

"Kuda Bank" already uses that name. Rails are matched on the name with spelling and punctuation removed, so these two would be the same rail.

Merging is the repair script's job, and it prints a plan first.

"Did you mean…" is offered, never applied

The exact-slug collision above is caught by the unique index. A separate check catches the shapes it cannot — a typo (payapl), a plural (banktransfers), or one name contained in another (wise vs wisetransfer) — using containment plus a Levenshtein distance of one.

That check runs on the trader's rail-creation path (POST /api/p2p/payment-rail), which answers with status: "similar" and a list of suggestions rather than an error: the suggestions are the useful part and an error cannot carry them. The trader either adds their details to one of the suggested rails or confirms and creates a separate one. Nothing is ever merged on their behalf.

Traders may define up to 12 rails each.

/admin/p2p/payment-method/setup — the Country Setup console

A fresh install ships three generic global methods — Bank Transfer, PayPal, Cash — and every local rail has to be typed in by hand, one CRUD form at a time. So most installs launch with three methods nobody in their market uses and a P2P board that is dead on arrival: a Nigerian maker cannot offer Opay, an Iraqi maker cannot offer Zain Cash, a Brazilian maker cannot offer Pix.

Pick a country, see what people there actually pay with, tick, create.

Lists the platform-level methods already on this install
Bulk-creates the ticked methods as global
  1. Pick a country. Curated countries show a local list; the euro area has its own bundle under the pseudo-code EUR.

  2. Read what is already there. Methods that already exist on the install are shown, disabled and labelled, rather than filtered out — so it is clear you are not missing them.

  3. Tick and create. One request, one snapshot of the table, one loop. Every row comes back as created, skipped or failed, and the skips and failures always say why.

Two things this console does that a per-row create cannot:

  • It writes isGlobal: true with a null owner. The user-facing POST /api/p2p/payment-method hard-codes isGlobal: false and stamps the caller as owner, so an operator using it creates methods only they can attach to an offer. That is the defect this screen exists to remove.
  • Its duplicate check matches what a trader can actually see. The single-create route next door only looks for rows with isGlobal = true, but the methods a fresh install ships are seeded with userId = NULL and isGlobal = 0 and are served to every maker regardless. This route matches on isGlobal = true OR userId IS NULL, which is the only definition that avoids minting a second PayPal on an install that already has a working one. A trader's own method named "Opay" is deliberately not counted — it belongs to that maker, cannot be attached by anyone else, and must not block you.

The console never writes metadata, even though the request shape could carry it. A method's metadata is spread into every trade's paymentDetails snapshot, and on a global method that map is shared by every maker on the install — so a value typed here would be published as each seller's own account details. The per-method field list is folded into the row's instructions instead, phrased as what the seller supplies.

Fees are left empty on purpose. fees is free text you set per install, and a wallet's fee schedule changes far faster than its name does.

Limits: 60 methods per request, names capped at 100 characters, processingTime at 50, fees at 50.

The one-time rail repair

The script merges every distinct spelling of a name into one rail. That merge is the fix, and it is also the one decision here that is awkward to reverse by hand. Run it without --apply first and read the MERGE lines.

node backend/scripts/repair-p2p-payment-rails.mjs            # plan only
node backend/scripts/repair-p2p-payment-rails.mjs --apply    # write it

The script resolves ../../.env relative to its own location. Running it from backend/ with a plain dotenv/config found nothing and the connection came up with no database selected, which then failed on the first query rather than on connect. Run it as written above.

What it does, in order:

  1. One rail per distinct normalised name across every existing payment method. "PayPal", "Paypal" and "pay pal" become one rail, not three. The display name comes from the platform's own global row where there is one; otherwise the most common exact spelling wins, with the earliest row breaking a tie so the result is deterministic.
  2. The field shape. A rail whose slug matches the shipped catalogue takes the catalogue's shape. Anything else has its shape inferred from the metadata keys the accounts on it actually use, ordered by how many accounts carry each key, capped at 12, with required reserved for a key present on every account that has details — marking a field required that an existing account lacks would stop that trader saving their own row.
  3. Every method row gets railId, and its name is rewritten to the rail's spelling. That second part is deliberate: the offer chips, the admin list and the trade snapshot all read name off the method row, so leaving the old spelling would show "Pay Pal" on an offer whose filter entry says "PayPal".

Rails it creates or updates are set listed = true and available = true — these are the platform's existing set, and hiding them would empty the filter on upgrade. Trader-created rails added after the repair start unlisted and are promoted by use.

The plan output names, per rail: create or update, the display name, how many accounts attach to it, where the shape came from, and — on a merge — every spelling being combined. Rows whose name reduces to an empty slug are reported and left unattached.

The whole apply pass runs in one transaction and rolls back on any error.

The offer join table (p2p_offer_payment_method) and the trade payment snapshot still point at the method row, exactly as before. Repointing them at the rail would have rewritten, in one migration, the rows that decide where a buyer sends money and what an offer accepts. The duplication complaint is entirely about how the filter is keyed, and grouping the filter by railId answers it without touching a single live offer or trade.

Trades already open keep the payment details copied at initiation. Those are a record of what was true then.

The shipped rail catalogue

These are the slugs the repair matches against for a field shape, defined in CATALOGUE_RAILS:

Bank Transfer · SEPA Instant · PayPal · Wise · Revolut · Zelle · Cash App · UPI · IMPS / NEFT · Pix · M-Pesa · Mercado Pago · GCash · Papara · Cash in Person

Each is deliberately short — every field on them is one a buyer genuinely cannot pay without. "Reference" and "Amount" are not among them, because the trade adds both by itself.

This is a different list from the Country Setup console's catalogue, which is a much larger per-country list of local wallets and banks. The rail catalogue supplies field shapes; the setup catalogue supplies methods to create.