Reputation scores, reviews and what an operator can change
The hourly reputation job, its exact formula, why the score it computes is read by nothing, what traders actually see instead, and the two levers an operator really has.
"Why did my score drop?" is a P2P support ticket you will get, and the honest answer is stranger than the question. There are two reputations in this addon and they are not the same number:
| Where it comes from | Who sees it | |
|---|---|---|
| The computed score | the hourly updateP2PReputationScores cron |
nobody — it is written to a log row and read by nothing |
| The trader card figures | recomputed from p2p_trades and p2p_reviews on every request |
every customer, on the market board, the traders lens and the picks |
So the score the cron works out is not the number a trader is looking at when they complain, and changing one would not move the other. This page documents both, because you cannot answer the ticket without knowing which is which.
The hourly job
Registered in the p2p category on /admin/system/cron as Update P2P
Reputation Scores, period one hour. It needs the cron process running, the
same as everything else in P2P — see Install.
Who it looks at. Two DISTINCT queries over p2p_trades, one on buyerId
and one on sellerId, for trades created in the last 30 days. The two lists
are merged and deduplicated. A trader who has been quiet for a month is not
recomputed at all; their last score simply stands.
What it counts, per user. The 30-day window selects who is processed. The figures themselves are lifetime, with no date bound:
| Figure | Query |
|---|---|
completedTrades |
trades where the user is buyer or seller and status = 'COMPLETED' |
totalTrades |
trades where the user is buyer or seller and status <> 'PENDING' |
disputedTrades |
rows in p2p_disputes with againstId = the user and status = 'RESOLVED' |
avgRating |
the mean of AVG(communicationRating), AVG(speedRating) and AVG(trustRating) over every review where revieweeId = the user |
The formula
score = 50 base
+ (completedTrades / totalTrades) * 30 completion rate, when totalTrades > 0
+ (avgRating / 100) * 20 reviews, when the user has any
- min(disputedTrades * 5, 20) disputes resolved against them
score = round(clamp(score, 0, 100))Three things about it are worth knowing before you quote it to anybody.
The completion-rate denominator is not the one on the trader card. Here it
is every trade that is not PENDING — which includes PAYMENT_SENT and
DISPUTED trades that are still running. The customer-facing figure divides by
finished trades only (COMPLETED, CANCELLED, EXPIRED). A trader with live
trades in flight therefore scores lower here than the card in front of them
says.
The dispute deduction is outcome-blind. It counts p2p_disputes rows where
the user is the againstId and the status is RESOLVED, regardless of who won.
A trader who was accused, disputed, and vindicated still loses five points the
moment you close the case. The deduction caps at 20, so only the first four
resolved disputes count for anything.
The floor in practice is 30, not 0. Base 50 minus the maximum 20-point
deduction. The clamp(0, 100) exists for safety, not because the arithmetic can
reach it.
The rating term is applied under a truthiness test, so an avgRating of 0 —
a trader rated 0 on all three dimensions by everyone — is treated the same as a
trader who has never been reviewed. Both simply get no rating points. It is a
distinction the score cannot express.
Where the score goes, and why it does not matter
Nothing in the schema stores a reputation score. The only home the computed
value has is a row in p2p_activity_logs:
type REPUTATION_UPDATE
action REPUTATION_SCORE_CALCULATED
details {"reputationScore":…, "completedTrades":…, "totalTrades":…,
"avgRating":…, "disputedTrades":…, "completionRate":"0.98",
"lastUpdated":"…"}The only code that ever reads that row back is the job itself, on its next run, to decide whether the value has changed. No API returns it, no screen renders it, and no trading decision consults it.
The row is written only when the score actually moves. The job finds the
most recent REPUTATION_SCORE_CALCULATED row for the user (including
soft-deleted ones), reads reputationScore out of its details, and skips the
insert when the new value matches. That is why the activity log is no longer
flooded — see
Troubleshooting
for the older behaviour and what it hid.
On /admin/p2p/activity these rows sort under the System category and
Routine severity, because the category is derived from the type string and
REPUTATION_UPDATE matches none of trade, dispute, payment or user. The search
box matches the type, the details blob and the related entity id — it never
looks at action, which is a separate column. So the string to search for is
REPUTATION_UPDATE, the type. Searching REPUTATION_SCORE_CALCULATED finds
nothing, because that value only ever lives in action; a user id finds nothing
either, since these rows leave relatedEntityId empty and the details blob
carries figures only, no ids.
The job cannot report a failure
Every error inside it — the whole run, or one user's five queries — is caught and logged, never rethrown. The cron manager only marks a job failed when its handler throws, so this job reports "completed" on the cron screen whether or not it did any work. Its only honest trace is the backend log:
P2P Updating reputation for 412 users
P2P Failed to update reputation for user <id>
P2P Reputation update errorThe cost also grows with your active-trader count and nothing batches it: the job runs roughly five queries per selected user, every hour, with no cap. The one-minute timeout job caps its scans at 500 rows per tick; this one does not.
Milestones
Separate from the score, and the one part of this machinery a customer actually notices. At 10, 50 and 100 completed trades the job sends a "Milestone Reached!" notification over in-app, push and email.
Only the highest milestone a user has reached is considered, and only once:
the job looks for an existing p2p_activity_logs row with type = REPUTATION_MILESTONE and action = MILESTONE_10 / _50 / _100 — with
soft-deleted rows included — and does nothing if one exists.
Two consequences:
- A trader who jumps from 8 completed trades to 55 between two hourly runs is notified about 50 and never about 10. The lower milestone is not backfilled.
- The milestone row is written before the notification is sent, deliberately. A crash between the two loses the notification rather than repeating it every hour forever, which is the right way round for something that sends email.
notifyReputationEvent sets the notification link to /p2p/profile. No such
page exists in this build — there is no profile route under /p2p at all — so
a recipient who clicks through lands on a 404. Nothing else about the milestone
is affected; the notification itself delivers normally.
What reviews actually are
p2p_reviews stores three independent scores, each 0–100:
communicationRating, speedRating and trustRating. There is no rating
column. The customer UI submits a single 1–5 star value and the review
endpoint spreads it across all three (stars / 5 * 100); an API caller may send
the three dimensions individually instead.
This was not theoretical. The reputation job used to average a nonexistent
rating column, which threw for every user — so reputation was never
computed at all — and the trader dashboard's counterparty ratings did the same
inside a bare catch, reporting every counterparty as rated 0 with no way to
tell "unrated" from "rated badly". If you write a report or an export against
this table, average the three real columns.
Other rules the review endpoint enforces, all in code and none configurable:
| Rule | Value |
|---|---|
| When a review may be left | only on a trade in COMPLETED |
| How many | one per trade per reviewer |
| Who is reviewed | always the counterparty, never the reviewer |
| Feedback length | 2,000 characters, truncated not rejected |
| Rate limit | the shared p2pTradeAction bucket, 50 per hour |
The duplicate check runs with soft-deleted rows included, so removing a review does not let the same trader submit a replacement for that trade.
See Reviews for the trader-facing description.
What traders actually see
Every customer-visible reputation figure is recomputed from p2p_trades and
p2p_reviews at request time by one shared aggregator. It is the same numbers
on all of these:
| Surface | Route |
|---|---|
| Market board | GET /api/p2p/market/board |
| Traders lens | GET /api/p2p/market/traders |
| Market picks ("find me someone") | GET /api/p2p/market/picks |
| Pre-publish offer forecast | GET /api/p2p/offer/forecast |
| The trader's own trades dashboard | GET /api/p2p/trade |
What it publishes per trader:
| Field | Meaning |
|---|---|
completedTrades |
trades in COMPLETED, either side |
finishedTrades |
trades in COMPLETED, CANCELLED or EXPIRED |
completionRate |
0–100, or null when there is nothing finished to divide by — never defaulted to 100 |
avgReleaseSeconds |
mean seconds from paymentConfirmedAt to completedAt, as the seller only |
avgRating |
mean of (communicationRating + speedRating + trustRating) / 3 across their reviews |
reviewCount |
number of reviews received |
isNewTrader |
no finished trades and no reviews — presented as unknown, not as spotless |
Four more surfaces compute their own version rather than using the shared aggregator:
- the offer permalink (
GET /api/p2p/offer/{id}) reports the seller's record with atotalTrades/completedTrades/completionRate/isNewSellershape plus the three review dimensions separately; POST /api/p2p/guided-matchingcomputes completion rate and average rating inline for its match score;- the trade detail both parties watch (
GET /api/p2p/trade/{id}) works outcompletedTrades,finishedTrades,completionRateandisNewTraderper counterparty in a local helper of its own; - the P2P dashboard (
GET /api/p2p/dashboard) derives each counterparty's rating and review count from its own grouped average overp2p_reviews.
They agree on the definitions; they are just different code paths, so a discrepancy between two screens is worth checking against both rather than assuming one is broken.
None of these read the cron's score. If you stopped the reputation job entirely, no customer-visible number would change.
The guided-matching engine awards its Highly rated badge when a trader's
average is >= 4.5, a threshold written for a 1–5 star scale. The value it
tests is the mean of the three 0–100 columns. A single one-star review resolves
to 20, so effectively every trader who has ever been reviewed carries the badge.
Its sibling labels — High completion rate at > 90 and New trader when
both figures are null — are on the right scale and behave correctly. Worth
knowing before you take a customer's word that the badge means something.
What you can and cannot change
The admin screens under /admin/p2p are enumerated on
Moderating offers and trades, and none of them is a
reputation screen or a review list. There is no way to edit a score and no
endpoint that deletes a review. Nothing on the P2P settings page affects the
formula either — the weights, the milestones and the 30-day window are all fixed
in code.
That leaves three real levers.
1. Resolving disputes
Every dispute you close as RESOLVED adds five points of deduction against the
againstId party in the computed score, up to the 20-point cap — and it does so
whichever way you ruled. Since nothing reads the score, this changes no customer
outcome today; it matters because it is the one input an operator directly
controls, and because disputesAgainst is shown on the case screens.
2. Removing a review — database only, and it is a soft delete
p2p_reviews is paranoid, so setting deletedAt removes a review from the
averages — the trader cards, the offer permalink, the trades dashboard and the
reputation job all read it through the ORM and honour the flag. There is no
product door for this.
One endpoint does not honour it: GET /api/p2p/offer/popularity is raw SQL and
joins p2p_reviews with no deletedAt filter, so a removed review keeps
contributing to an offer's popularity ranking. It affects ordering only, and
nothing else reads that route.
A review is a customer's statement about a counterparty. Deleting one silently
edits a stranger's trading record and there is no audit trail for it — the
admin activity log records admin actions taken through the API, and a direct
UPDATE is not one of them. If you remove a review, write what you did and why
into the trade's internal note from
the trade case desk, so the two records agree.
The reviewer cannot replace it: the "already reviewed this trade" check includes soft-deleted rows on purpose, so moderation cannot be undone by resubmitting.
3. Moderating the account and its offers
The lever that actually protects your other customers is not the score, which
nobody sees, but the offers. Flag, pause, reject or disable them from
/admin/p2p/offer — see Moderating offers and trades. A
paused or disabled offer releases its escrow and leaves the board; a flag is
visible without taking a live offer away from its counterparties.
The record the case screens show instead
The dispute and trade case screens do not show the computed score. They show a purpose-built party record, built per party by the admin dispute and trade endpoints, and it is more useful than the score for the decision being made:
| Field | Definition |
|---|---|
completedTrades |
lifetime COMPLETED, either side |
finishedTrades |
lifetime COMPLETED + CANCELLED + EXPIRED |
completionRate |
rounded percent, null when finishedTrades is 0 — never 100 |
isNewTrader |
finishedTrades === 0 |
disputesFiled |
p2p_disputes rows with reportedById = this user |
disputesAgainst |
p2p_disputes rows with againstId = this user |
The last two are the pair that decides cases. "Filed nine, had none filed
against" describes a very different account from the reverse. As a per-party
figure disputesFiled appears nowhere else in the product, but
disputesAgainst does: the admin offer detail screen (/admin/p2p/offer/{id})
publishes the same count for the maker and renders it as a Disputes against
stat in its "Who is offering it" block. It turns red at three or more on both
screens, by the same rule.
completionRate being null rather than 100 for a brand-new account is
deliberate on this screen above all others: it is where a reputation the
platform has not earned the right to vouch for would turn into a payout. Full
walkthrough on Working a trade case and
Resolving a dispute.
Answering the ticket
When a trader says their score dropped:
-
Establish which number they mean. Almost always it is the completion rate or the star average on their trader card, not a "reputation score" — the score is not rendered anywhere they can see.
-
Check the denominator. A cancelled or expired trade counts against completion rate exactly as much as it counts for having been finished. A trader who let two payment windows lapse has a real, correct drop.
-
Check for a new review. One review from a trade that went badly moves the average sharply on a low
reviewCount. -
Look at the log if you want the computed score. It is the newest
REPUTATION_SCORE_CALCULATEDrow for that user inp2p_activity_logs, and itsdetailsblob shows every input that produced it.SELECT createdAt, details FROM p2p_activity_logs WHERE userId = 'USER_ID' AND action = 'REPUTATION_SCORE_CALCULATED' ORDER BY createdAt DESC LIMIT 5; -
Do not promise a correction you cannot make. There is no door that edits a score or a rate. The only thing you can genuinely remove is an abusive review, and that is a database edit you should be reluctant to make.