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.
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
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.
-
Pick a country. Curated countries show a local list; the euro area has its own bundle under the pseudo-code
EUR. -
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.
-
Tick and create. One request, one snapshot of the table, one loop. Every row comes back as
created,skippedorfailed, and the skips and failures always say why.
Two things this console does that a per-row create cannot:
- It writes
isGlobal: truewith a null owner. The user-facingPOST /api/p2p/payment-methodhard-codesisGlobal: falseand 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 withuserId = NULLandisGlobal = 0and are served to every maker regardless. This route matches onisGlobal = 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 itThe 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:
- 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.
- 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
requiredreserved 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. - Every method row gets
railId, and itsnameis rewritten to the rail's spelling. That second part is deliberate: the offer chips, the admin list and the trade snapshot all readnameoff 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.