Remaining docs updated to match code (the docs that the first pass had not covered):
- Flows: Chat, Referral, Rating, Registration, Google OAuth, Negotiation, Payout,
Trezor Safekeeping — corrected endpoints, socket events, status enums, auth gaps
- API Reference: User API, Trezor API — admin route prefix/verb/status corrections,
added undocumented endpoints (ton-proof challenge, profile email verify,
GET /trezor/account, POST /trezor/verify-operation)
- Data Models: Chat, Notification, Payment, PointTransaction, User — corrected
enums (PaymentProvider, escrowState, PointTransaction.type, User.status),
90-day notification TTL, soft-delete semantics, wallet fields
Trezor "zero frontend" finding (audit C31/C32) corrected as STALE:
- Verified current code HAS a full frontend Trezor implementation (admin/trezor
page, TrezorSettingsView, trezorConnector via @trezor/connect-web,
TrezorSignDialog, actions/trezor.ts building the {message,signature} object)
- Fixed Trezor Safekeeping Flow doc (removed false "no frontend" warnings)
- Reclassified ISSUE-012 as invalid/superseded with explanation
Issue set reconciled to a single canonical numbering (ISSUE-001..054):
- Adopted the comprehensive 51-issue set (long-slug, fully indexed)
- Removed 35 superseded short-slug duplicates from the first pass
- Removed a duplicate ISSUE-046 file
- Added 3 issues the 51-set lacked: ISSUE-052 (completed-not-counted-in-stats),
ISSUE-053 (axios 401-only interceptor), ISSUE-054 (rate limiter counts all attempts)
- Regenerated Issues Index: 53 open (14 critical, 39 major) + 1 invalid
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
15 KiB
title, tags, related_models, related_apis
| title | tags | related_models | related_apis | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Referral Flow |
|
|
|
Referral Flow
Last updated: 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
Each user can generate a personal referral code, share a short URL, and earn points/commission when their referees sign up and complete purchases. Codes can also be entered at sign-up time (Phase 1 attribution) — see Registration Flow.
Actors
- Referrer — the user with the code.
- Referred user — the new sign-up.
- Backend —
PointsService(backend/src/services/points/PointsService.ts),authController(backend/src/services/auth/authController.ts), points routes atbackend/src/routes/pointsRoutes.ts. - MongoDB —
users(referralCode,referredBy,referralStats,points),pointtransactions,levelconfigs. - Socket.IO —
referral-signup(auth domain) andreferral-reward/level-up(points domain) events.
Preconditions
- Authenticated user (for referral-code generation and points endpoints).
- The 8-character code is unique (alphabet excludes I/O/0/1 to avoid confusion —
PointsService.ts:13).
Step-by-step narrative
1. Code generation
- User opens the points dashboard. If they don't have a code yet, they receive one automatically (
getUserPointslazily generates one —PointsService.ts:216-219). - A manual
POST /api/points/generate-referral-codeis also available. PointsService.generateReferralCode(userId)(:12-31):- Loops generating an 8-character code from
ABCDEFGHJKLMNPQRSTUVWXYZ23456789until uniqueness is confirmed byUser.findOne({ referralCode }). - ALWAYS overwrites the user's existing code via
User.findByIdAndUpdate(userId, { referralCode: code })(:29). There is no idempotency / noforceflag — any param in the request body is ignored. Calling this endpoint rotates (replaces) the code every time, invalidating previously shared links. - Returns it.
- Loops generating an 8-character code from
- Frontend renders the share URL
${NEXT_PUBLIC_API_URL}/r/${referralCode}(pointing to the backend API URL, not a frontend URL) and a copy button. This is constructed infrontend/src/sections/points/points-invite-friends.tsx:35-36.[!warning] Share link points at the wrong base The link is built from
NEXT_PUBLIC_API_URL(the backend) rather than the frontend origin. The/r/:coderedirect on the backend then bounces the user to the frontend sign-up — so it functions, but the surfaced URL is the API host, which is not the intended public-facing brand URL.
2. Short-URL redirect
- When a friend clicks the share URL,
GET /r/:code(backend/src/app.ts:274-278) redirects to${FRONTEND_URL}/auth/jwt/sign-up?ref={code}. - The sign-up form reads
?ref=and pre-fills the referral field (hidden or visible).
3. Attribution at sign-up
- During Registration Flow verification (or Google OAuth Flow sign-up), the controller looks up
User.findOne({ referralCode }):- Sets
user.referredBy = referrer._idon the new user. - Increments
referrer.referralStats.totalReferrals. - Emits
referral-signuptouser-{referrerId}with the referee's name, email, and updated total — emitted fromauthController.ts, not from PointsService.
- Sets
- At this point the referee is counted but no points have changed hands yet. Points award on subsequent business events.
[!danger] No self-referral guard There is no check preventing a user from using their own referral code. A user who enters their own code at sign-up (or any flow that sets
referredBy) is not blocked at the controller or service level. This is a known gap — add a guard such asif (referrer._id.equals(user._id)) returnin the email and Google sign-up paths.
4. Points awarding
- The only caller that awards referral points is
marketplaceController.ts, which invokesPointsService.processReferralReward(id)only when an order transitions to'completed'(marketplaceController.ts:473-475, insideif (newStatus === 'completed')). It is NOT triggered on'delivered','delivery','seller_paid', or any other status. PointsService.processReferralReward(purchaseRequestId)(:372-429):- Loads the purchase request, finds the buyer and the buyer's
referredByreferrer (returnsnullif either is missing). - Computes
referralPoints = Math.floor(amount * 0.02)— a flat 2% commission on the selected offer's price. - Calls
PointsService.addPoints(referrerId, referralPoints, 'referral', {...}). - Recomputes
referrer.referralStats.activeReferralsas a count of ALL users withreferredBy = referrer._id(:409-411) — this includes referrals that never purchased; it is not scoped to converted referrals. - Increments
referrer.referralStats.totalEarned. - Emits
referral-rewardtouser-{referrerId}(:417).
- Loads the purchase request, finds the buyer and the buyer's
- Inside
addPoints(:36-113):- Transaction-scoped Mongo session.
user.points.total += amount; user.points.available += amount.PointTransaction.create({ type:'earn', source, amount, balance, metadata }). Forsource === 'referral',metadata.commissionis set to the amount.updateUserLevel(userId, session)recomputes the user's tier fromLevelConfig.- Emits
level-uponuser-{userId}if the level changed (:91-99).
- Note: only the referrer earns points via this path. There is no "referee also earns" reward in the current code — the referee gets nothing automatically.
5. Redemption
- Users see their balance under
/dashboard/pointsand can spend viaPOST /api/points/redeem(applied as a discount against a specific purchase request). redeemPoints(userId, pointsToUse, purchaseRequestId)(:118-167):- Requires both
purchaseRequestIdandpointsToUse(controller returns400if either is missing orpointsToUse <= 0). - Throws
Insufficient pointsifuser.points.available < pointsToUse. - Decrements
available, incrementsused, and records aPointTransactionwithtype: 'spend',source: 'redemption'. - The controller computes
discount = pointsToUse * 1000(1 point = 1000 IRR, always) and returns{ transaction, discount, remainingPoints }. There are noamount/purpose/newBalance/redemptionfields in the response.
- Requires both
Sequence diagram
sequenceDiagram
autonumber
actor R as Referrer
actor N as New User
participant FE as Frontend
participant BE as Backend
participant DB as MongoDB
participant IO as Socket.IO
R->>FE: Generate referral code (or auto-assigned)
FE->>BE: POST /api/points/generate-referral-code
BE->>DB: User.findByIdAndUpdate(referralCode=...) (ALWAYS overwrites)
BE-->>FE: { referralCode }
R->>R: share ${NEXT_PUBLIC_API_URL}/r/{code} (backend URL)
N->>BE: GET /r/{code}
BE-->>N: 302 → /auth/jwt/sign-up?ref={code}
N->>FE: Fills sign-up, completes email verification
FE->>BE: POST /api/auth/verify-email-code (with referralCode in TempVerification)
BE->>DB: User.create
BE->>DB: referrer.referralStats.totalReferrals += 1
BE->>IO: emit user-{R} 'referral-signup' (authController)
Note over BE,DB: ONLY when N's order reaches status 'completed'
BE->>BE: marketplaceController → PointsService.processReferralReward(id)
BE->>BE: addPoints(R, floor(amount*0.02), 'referral', {...})
BE->>DB: add points to balance + create PointTransaction
BE->>BE: updateUserLevel → maybe 'level-up'
BE->>IO: emit user-{R} 'level-up'
BE->>DB: activeReferrals = count(referredBy=R) (ALL, not just buyers)
BE->>IO: emit user-{R} 'referral-reward' (PointsService)
API calls
[!note] All points routes require authentication
router.use(authenticateToken)is applied to every route inpointsRoutes.ts:8. None of these endpoints — includingGET /api/points/levels— are public.
| Method | Endpoint | Auth | Body / Query | Response data |
|---|---|---|---|---|
POST |
/api/points/generate-referral-code |
user | (ignored) | { referralCode } — always rotates the code |
GET |
/api/points/my-points |
user | — | { points, referral, currentLevel, nextLevel } |
GET |
/api/points/transactions |
user | page, limit, type (earn/spend/expire only) |
{ transactions, pagination } |
GET |
/api/points/referrals |
user | page, limit |
{ referrals, pagination } |
GET |
/api/points/leaderboard |
user | limit only (period is NOT supported) |
{ leaderboard, total } |
GET |
/api/points/levels |
user (NOT public) | — | { levels } |
POST |
/api/points/redeem |
user | { pointsToUse, purchaseRequestId } (both required) |
{ transaction, discount, remainingPoints } |
POST |
/api/points/admin/add |
admin | { userId, amount, description } |
{ transaction, user, levelChanged, newLevel } |
GET |
/r/:code |
public | — | 302 redirect to sign-up |
Endpoint notes (verified against code)
GET /api/points/transactions—typefilter only acceptsearn,spend, orexpire(PointsService.ts:250-265). There is no source-based filtering: you cannot filter byreferral/purchase/admin/redemption.GET /api/points/leaderboard— theperiodfilter (all/month/week) does not exist and is silently ignored.getLeaderboard(limit)only honorslimitand always returns all-time data sorted bytotalReferralsthentotalEarned(PointsService.ts:434-479).POST /api/points/admin/addreads{ userId, amount, description }(the field isdescription, notreason). However thedescriptionis read but never persisted — the controller callsaddPoints(userId, amount, 'admin', {})with an empty metadata object (pointsController.ts:209), so admin-granted points store no human-readable reason. The stored description is the generic auto-generated'admin'label fromgetTransactionDescription.
Database writes
users:referralCodeon generation/rotation,referredByon referee creation,referralStats.{totalReferrals, activeReferrals, totalEarned}andpoints.{total, available, used, level}on point events.activeReferralsis set byPointsService.processReferralReward(:409) as a count of all users withreferredBy = referrer._id, regardless of purchase history.pointtransactions: one document perearn/spendevent. (expireis defined in the schema but never written — see below.)levelconfigs: read-only at runtime (seeded at deploy).
Socket events emitted
referral-signup→user-{referrerId}on referee creation — emitted byauthController.ts; this is an auth-domain event (NOT emitted byPointsService).referral-reward→user-{referrerId}whenPointsService.processReferralRewardruns — emitted byPointsService.ts:417; this is the points-domain event. (There is noreferral-signupemitted from PointsService.)level-up→user-{userId}when crossing a tier (PointsService.ts:92).
Side effects
points.availableis the spendable balance;points.totalis the lifetime accumulation (used for level tiers);points.usedtracks redeemed points.- Transactions are wrapped in a Mongo session for atomicity (
addPoints:47-88,redeemPoints:123-153).
Error / edge cases
- Code collision — extremely unlikely with 32^8 ≈ 1.1 × 10¹² combinations; the while-loop in
generateReferralCodeis a hard guarantee. - Self-referral — NOT blocked at any level (see danger callout above). Known gap.
- Code rotation on regenerate — calling
generate-referral-codeagain replaces the existing code, breaking previously shared links. There is no opt-out. - Referrer deleted —
referredBystill points to the deleted user; the new user is effectively un-attributed. - Point expiry never enforced — the
expiresAtfield and the'expire'transaction type exist in the schema, and there is a sparse index for expiry sweeps, but no cron job, TTL index, or service ever createsexpire-type transactions. Points never actually expire today. activeReferralssemantics — counts all referred users, not just those who completed a purchase. If conversion tracking is the intent, this counter is misleading.
[!tip] Track conversion, not just sign-ups
totalReferralsis incremented on sign-up andactiveReferralscounts all referees regardless of purchase; neither distinguishes converted referrals. Consider a dedicatedconvertedReferralscounter incremented only insideprocessReferralReward.
Frontend coverage (known gaps)
The following routes are referenced conceptually but do NOT exist — navigating to them returns 404:
/dashboard/points/referrals— 404 (no page file)/dashboard/points/transactions— 404 (no page file)/dashboard/points/levels— 404 (no page file)
Only /dashboard/points (frontend/src/app/dashboard/points/page.tsx) exists.
The following frontend actions are defined in frontend/src/actions/points.ts but have no UI callers (dead code from the UI's perspective):
redeemPoints— no caller.generateReferralCode— no caller (codes are auto-assigned server-side viagetUserPoints).getLevels— no caller.getReferrals— no caller.adminAddPoints— no caller.
Only getMyPoints, getTransactions, and getLeaderboard are actually invoked by the UI (points-main-view.tsx, points-leaderboard.tsx).
Linked flows
- Registration Flow — attribution point.
- Google OAuth Flow — also supports
referralCode. - Notification Flow —
referral-signup,referral-reward,level-upsurface here. - Escrow Flow — order reaching
'completed'is the sole trigger for awarding referral commission.
Source files
- Backend:
backend/src/services/points/PointsService.ts - Backend:
backend/src/controllers/pointsController.ts - Backend:
backend/src/routes/pointsRoutes.ts - Backend:
backend/src/models/PointTransaction.ts - Backend:
backend/src/models/LevelConfig.ts - Backend:
backend/src/services/marketplace/marketplaceController.ts:473-475(referral reward triggered ONLY on'completed') - Backend:
backend/src/services/auth/authController.ts(referral attribution +referral-signupemit on email/Google signup) - Backend:
backend/src/app.ts:274-278(short-URL redirect) - Frontend:
frontend/src/sections/points/points-invite-friends.tsx:35-36(builds share URL fromNEXT_PUBLIC_API_URL) - Frontend:
frontend/src/actions/points.ts(action layer; several actions have no UI callers)