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.

10 min readUpdated 6 August 2026reputation, reviews, cron, ratings, disputes

"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 error

The 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 a totalTrades / completedTrades / completionRate / isNewSeller shape plus the three review dimensions separately;
  • POST /api/p2p/guided-matching computes completion rate and average rating inline for its match score;
  • the trade detail both parties watch (GET /api/p2p/trade/{id}) works out completedTrades, finishedTrades, completionRate and isNewTrader per 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 over p2p_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:

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

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

  3. Check for a new review. One review from a trade that went badly moves the average sharply on a low reviewCount.

  4. Look at the log if you want the computed score. It is the newest REPUTATION_SCORE_CALCULATED row for that user in p2p_activity_logs, and its details blob 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;
  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.