Working a trade case at /admin/p2p/trade/[id]
The full-screen admin trade case — the ribbon, the four progress steps, the posture that replaces the raw status, the countdown, the payment snapshot, and the four doors that move escrow.
/admin/p2p/trade/<id> is the one screen on this platform where an operator
moves somebody else's escrow. Everything on it is arranged around that fact: the
figures you are authorising are on screen while you authorise them, the buttons
say the amount rather than "Confirm", and a door the API would refuse is not
drawn at all.
Reading the case needs view.p2p.trade. Opening any door needs
edit.p2p.trade — treat that key like a withdrawal-approval permission, not
like a moderation one.
Every door commits inside one database transaction and settles the escrow
through the same authority the seller's own release uses. There is no reversal
door, no "undo last ruling", and COMPLETED and CANCELLED are terminal. Read
the conversation before you press anything.
The page has no admin navigation
This route is the only P2P screen in the addon layout's FULL_SCREEN_ROUTES
(the pattern /admin/p2p/trade/<something> with nothing after it, so the trades
list keeps its nav). On it, no site header is rendered at all.
Two consequences an operator notices:
- The ribbon at the top is the only way off the page. It pins at the very
top of the viewport (
top-0) rather than under a navbar, so it stays put while you scroll a long conversation. Every other admin P2P detail page — offers, disputes — renders under the fixed navbar and pins its ribbon below it instead. - The licence gate still applies. Full-screen does not mean unlicensed; the
route is wrapped in the same
p2plicence gate as the rest of the section.
The ribbon is shared by all the admin P2P detail pages, and the back button is the first item in it, top-left, on every one of them. Next to it sits the breadcrumb and a copyable trade-id chip — it shows the first eight characters and copies the full id, because that is what support tickets quote. When a dispute exists, an Open the dispute button sits in the same bar, so you can move between the money (here) and the claim (there) without scrolling.
A Live / Offline dot reports the WebSocket. Live means status changes and new chat messages arrive as they happen. If the socket gives up, the page falls back to re-reading the trade every 15 seconds and the dot says Offline — that is a degraded page, not a broken trade.
The four steps
The ribbon draws the trade as four steps, in the order they actually happen:
| # | Step |
|---|---|
| 1 | Escrow funded |
| 2 | Buyer sends payment |
| 3 | Seller confirms receipt |
| 4 | Escrow released |
Each carries one of four states:
| State | Drawn as | Means |
|---|---|---|
done |
green tick | happened |
current |
filled circle | where the trade is now |
upcoming |
grey | not reached |
halted |
dimmed | the trade stopped before it got here |
halted is the state that matters. A disputed trade shows steps 1 and 2 done
and steps 3 and 4 halted — the escrow was funded, the buyer declared payment,
and nothing further will happen without a human. A cancelled or expired trade
shows step 1 done and everything after it halted.
Posture, not status
The headline and the ribbon badge are keyed on the case's posture, not on
the raw status, because PENDING says nothing about whether anyone needs to
look at it. Exactly one posture is true at a time.
| Posture | Badge | Headline eyebrow | What it is asking of you |
|---|---|---|---|
needs-ruling |
Needs a ruling | Your call | A dispute is open. The escrow is frozen until somebody rules. |
overdue |
Overdue | Stalled | The payment window closed and the trade is still open. |
watching |
In progress | Nothing to decide | Running to plan. Nothing to do but watch. |
settled |
Settled | Outcome | Terminal. The record is the deliverable. |
watching covers both a PENDING trade inside its window ("Waiting for buyer
to pay") and a PAYMENT_SENT trade ("buyer says they have paid — waiting on
seller"). Neither needs you. overdue is a PENDING trade whose window has
blown, and the subheading tells you which of two worlds you are in: auto-cancel
will pick it up on its next pass, or auto-cancel is switched off in P2P settings
and nothing will ever close it.
The clock
Only a PENDING trade carries a countdown, and it is the same window the
auto-cancel cron enforces — resolved from the offer's own tradeSettings
(autoCancel, then the legacy paymentWindow), falling back to
p2pDefaultPaymentWindow. The bar turns amber in the last 20 % and red once the
window is blown, at which point the label flips to "Overdue by".
PAYMENT_SENT deliberately has no countdown. Nothing auto-releases a trade
whose payment has been declared, so a timer there would run out and do nothing —
which teaches an operator to ignore the one countdown that is real. On a
PAYMENT_SENT trade the clock region of the ribbon draws nothing at all —
no countdown and no elapsed-since figure. If you need to know how long a trade
has been sitting on a declared payment, read the Opened and Last update
timestamps in the fact strip at the bottom of the page and do the subtraction
yourself.
The footer of the page states the window in minutes and appends
"(not enforced)" when p2pAutoCancelUnpaidTrades is off. Check that before
you conclude the cron is broken.
The case file
The left-hand column is the case, top to bottom.
The money
The escrowed amount gets the largest type on the page and its own tinted box, because it is the money that has not been decided yet. Under it sit four supporting figures: what the buyer pays (in the offer's price currency), what the buyer receives (in the traded coin), the rate, and the platform fee.
When the offer never recorded a price currency, the fiat figures are printed
bare and the page says so in an amber strip. The backend returns null rather
than guessing "USD" — on the screen where somebody decides who gets paid, a
figure wrongly stamped with a dollar sign is worse than an unlabelled one. Check
the offer and the conversation before ruling on an amount.
Two escrow states are called out:
- Held — a green box. The money is still there and a door will move it.
- Still held on a terminal trade — a red box, and the same red on the ribbon
chip. The trade is closed but
escrowStatusis stillHELD, which means the settlement did not complete and nobody has been paid. This is a stuck payout. Escalate it rather than resolving from here; the doors will not be offered anyway, because the trade is terminal.
Who is involved
Two party cards, buyer and seller. Each carries the trade record the operator
actually adjudicates on: completed trades, completion rate, disputes filed
and disputes filed against — the lopsided pair is the signal, and the
"filed against" figure turns red at three or more. Completion rate is blank
rather than 100 % for an account with nothing finished. Each card links to the
user's core profile at /admin/crm/user/<id>.
The buyer's card carries the claimed payment: the reference the buyer typed when they pressed "I've sent it", and the moment they pressed it. That is the buyer's own claim, and it is the fact an operator adjudicating "did they pay?" starts from.
How it was meant to be paid
The payment-details snapshot lives on this page and nowhere else. The admin
trade endpoint returns paymentDetails (what the buyer was told to send money
to, captured when the trade opened), paymentMethod, and the method's own
paymentMethodDetails — its name and the instructions the buyer was shown —
plus the offer's written terms.
/admin/p2p/dispute/<id> serves the claim, the evidence and the transcript. It
does not carry the payment snapshot. If your question is "were these the
details the buyer was actually given?", it is answered here, on the trade case,
and the ribbon's Open the dispute button is how you move between the two.
The snapshot is the agreement. Anything posted in the chat afterwards is not.
The dispute, and what was settled
If a dispute exists, a red panel states who filed it against whom, when, its status and priority, the reason and details, and its evidence gallery.
Once the trade has been ruled on, a What was settled panel appears with the
outcome, the notes, the name of the admin who decided it, and the four
figures the settlement produced: buyer credited, seller refunded, platform fee,
escrow consumed. If the resolution was recorded but no escrow moved, an amber
line says so — the trade will read COMPLETED regardless, so check the wallet
ledger before telling anyone they have been paid.
The conversation
The trade chat sits permanently beside the case rather than behind a tab, because the transcript is the evidence and the amount is what you are comparing it against. The buyer is on the left, the seller on the right, and anything from support spans the full width in amber — the same shape both traders see, so a screenshot from either side reads the same way.
The composer below it broadcasts into that conversation. Both parties see it, both are notified, and it is capped at 1,000 characters — the same limit the trade chat enforces. Use it to state facts: what you have decided, and what you need from each side. Everything else belongs in an internal note, which allows 4,000 characters and is never shown to users.
Intervene: the four doors
The doors are derived from the backend's own transition rules, so the screen cannot offer an action the API refuses.
| Door | Offered when the status is | Trade becomes | Money |
|---|---|---|---|
| Award to the buyer | DISPUTED, PAYMENT_SENT, PENDING |
COMPLETED |
whole escrow to the buyer, less the platform fee |
| Split the escrow | DISPUTED, PAYMENT_SENT, PENDING |
COMPLETED |
divided by the share you set; fee on the buyer's portion only |
| Award to the seller | DISPUTED, PAYMENT_SENT, PENDING |
CANCELLED |
whole escrow refunded to the seller's spendable balance, no fee, offer capacity restored |
| Cancel the trade | anything except COMPLETED, CANCELLED, EXPIRED |
CANCELLED |
escrow un-committed back to the parent offer — no money moves |
cancel.post.ts refuses COMPLETED, CANCELLED and EXPIRED with a 400.
The old page showed a red Cancel button on expired trades because it only tested
for the first two, so it offered a control that could never do anything but
fail. An expired trade now draws no doors at all, and the Intervene panel says
why in words instead of showing an empty box.
Award to the seller is not the same as cancelling
This distinction is real money and it is the one most likely to be got wrong.
- Award to the seller refunds the escrow into the seller's spendable balance. The offer's advertised capacity is restored, capped at its original total and at the escrow genuinely still held.
- Cancel the trade hands the trade's share back to the parent offer without moving any money at all, so the seller can trade it again. If the offer can no longer hold it — a BUY offer, a deleted offer, one no longer collateralized — it falls back to refunding the seller.
Picking the wrong one silently decommits an offer. Cancel is for a trade that is stuck for reasons that are not a dispute. Award to the seller is a finding.
Splitting
SPLIT has always been accepted by the resolve endpoint, and until this rebuild
no UI anywhere could send it — so the only way to settle a genuinely 50/50
dispute was to pick a winner. The door now carries a buyer's share slider,
0–100 %, defaulting to 50.
The value is sent as a percentage, not an amount, so the server measures it
against the escrow it actually holds rather than against a figure this page
cached. (The endpoint also accepts an absolute buyerShare in the trade
currency; buyerPercentage wins if both arrive, and with neither the split is
an even 50/50.)
The settlement projection
Choosing a door draws the projection beside the button, on the page — not in a modal, because a modal covers the money at exactly the moment it should be re-read. Four figures: what the buyer receives net, what the seller receives, the platform fee, and the status the trade will land in.
It runs the same arithmetic the escrow authority runs:
- the fee is charged only on value delivered to the buyer, never on a refund — a seller getting their own funds back is not taxed for it;
- the fee is capped at the buyer's gross, which stops a minimum-fee floor from consuming a whole small trade;
- the seller's share is whatever is left.
It is a projection, not a promise. The server settles against the hold that actually exists and clamps to it, and it waives the fee entirely when the seller is a Super Admin, so the final figures can come back lower. The real ones are written to the page once the door has run.
The reason is required
The free-text reason is mandatory — the door will not commit without it — and it
lands verbatim in trade.resolution.notes and in the dispute's resolution,
which both parties can see.
Before the rebuild this field was a hardcoded string, "Resolved by admin in favor of buyer", identical on every trade ever resolved. That made the audit
trail worthless precisely where it matters. Write the finding, not the verdict:
"Bank statement supplied by the buyer shows the transfer landing at 14:32; the
seller stopped responding" is a record. "Resolved by admin" is not.
A cancellation reason is stored on the trade as cancellationReason, truncated
to 500 characters, alongside cancelledBy and cancelledAt.
Committing
Below the reason is an attestation checkbox — "I have read the conversation and the evidence, and I am authorising this outcome. It cannot be reversed." The commit button stays disabled until both it and the reason are filled in, and it says the amount and the party: "Release 0.0142 BTC to Jane Doe", never "Confirm".
-
Read the conversation and the timeline before touching a door. The loudest party is not usually the wronged one.
-
Pick the door. The projection appears beside it. Check the currency, the figures and the party names against what you just read.
-
Set the split share if you chose SPLIT.
-
Write the reason. Two sentences of finding. If you cannot state it in two, you are not ready to move somebody's money.
-
Tick the attestation and press the commit button, which names the amount and the recipient.
-
Read the strip that appears at the top of the page. It reports what the settlement authority actually did — see below.
Read the outcome strip, do not assume
After a door runs, a strip appears under the ribbon describing the settlement rather than reporting that a request returned 200:
| Strip | Means |
|---|---|
| green — "Settled — 0.0142 BTC to the buyer, 0.00003 BTC platform fee." | money genuinely moved, leg by leg |
| amber — "Recorded — but no escrow moved: this trade's hold had already been settled." | the ruling is on the record; the money is not where it says |
| amber — "Recorded — but no escrow moved. There was nothing held against this trade." | nothing was ever escrowed |
Both amber cases are real and both are worth acting on. They usually mean the seller already released, the expiry cron got there first, or another admin resolved the trade from a different door. Check the wallet ledger before telling anyone they have been paid, and see Resolving a dispute for what to do next.
The record at the bottom
Two panels side by side, plus a fact strip.
- Chronology — every status change, escrow movement and admin action, oldest first. Chat messages and internal notes are excluded; the backend splits the one stored timeline into three lists so an entry can never appear in two panels.
- Internal notes — staff-only, newest first, with an add box. Users never see these. This is where the reasoning behind a decision belongs, because the notification a user receives is short and the audit entry is structured rather than narrative.
- The fact strip — opened, payment window (with "not enforced" when auto-cancel is off), the offer side (BUY or SELL), the wallet type, last update, and links to the parent offer and the dispute.
Rate limit: there is none on these doors
Nothing on this page is rate limited. note.post.ts declares no
middleware at all. resolve.post.ts and cancel.post.ts do declare one —
middleware: [p2pAdminTradeRateLimit] — but they declare it as a function
reference, and the router resolves middleware only by string name
(rateLimiters[name] in backend/src/handler/Routes.ts). A function matches no
key there, so the request is logged as Unknown middleware and passes straight
through with no limit applied.
The limiter those two routes are reaching for is real — p2pAdminTrade, 100 per
hour per admin — and it does bite on the one admin trade route that names it as
a string, POST /api/admin/p2p/trade/timeout. It does not bite on resolve,
cancel or note. Treat the edit.p2p.trade permission as the only brake on
these three doors: a runaway script or a compromised admin session will not be
slowed by anything else. The admin offer and dispute limiters described on
Moderating offers and trades are
declared the same way, from the same import.