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.

12 min readUpdated 6 August 2026admin, trades, escrow, disputes, resolution

/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.

Reads the whole case file
Rules on the trade and settles the escrow
Cancels the trade and un-commits the escrow
Adds an internal note, or broadcasts into the trade chat

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 p2p licence 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 escrowStatus is still HELD, 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".

  1. Read the conversation and the timeline before touching a door. The loudest party is not usually the wronged one.

  2. Pick the door. The projection appears beside it. Check the currency, the figures and the party names against what you just read.

  3. Set the split share if you chose SPLIT.

  4. Write the reason. Two sentences of finding. If you cannot state it in two, you are not ready to move somebody's money.

  5. Tick the attestation and press the commit button, which names the amount and the recipient.

  6. 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.