Files
nick-doc/04 - Flows/Referral Flow.md
Siavash Sameni 7a616744f4 docs: complete code-reality alignment for remaining docs + reconcile issue set
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>
2026-05-29 15:15:02 +04:00

15 KiB
Raw Blame History

title, tags, related_models, related_apis
title tags related_models related_apis
Referral Flow
flow
referral
points
growth
User
PointTransaction
LevelConfig
POST /api/points/generate-referral-code
GET /api/points/referrals
GET /api/points/leaderboard

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.
  • BackendPointsService (backend/src/services/points/PointsService.ts), authController (backend/src/services/auth/authController.ts), points routes at backend/src/routes/pointsRoutes.ts.
  • MongoDBusers (referralCode, referredBy, referralStats, points), pointtransactions, levelconfigs.
  • Socket.IOreferral-signup (auth domain) and referral-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

  1. User opens the points dashboard. If they don't have a code yet, they receive one automatically (getUserPoints lazily generates one — PointsService.ts:216-219).
  2. A manual POST /api/points/generate-referral-code is also available.
  3. PointsService.generateReferralCode(userId) (:12-31):
    • Loops generating an 8-character code from ABCDEFGHJKLMNPQRSTUVWXYZ23456789 until uniqueness is confirmed by User.findOne({ referralCode }).
    • ALWAYS overwrites the user's existing code via User.findByIdAndUpdate(userId, { referralCode: code }) (:29). There is no idempotency / no force flag — any param in the request body is ignored. Calling this endpoint rotates (replaces) the code every time, invalidating previously shared links.
    • Returns it.
  4. 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 in frontend/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/:code redirect 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

  1. 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}.
  2. The sign-up form reads ?ref= and pre-fills the referral field (hidden or visible).

3. Attribution at sign-up

  1. During Registration Flow verification (or Google OAuth Flow sign-up), the controller looks up User.findOne({ referralCode }):
    • Sets user.referredBy = referrer._id on the new user.
    • Increments referrer.referralStats.totalReferrals.
    • Emits referral-signup to user-{referrerId} with the referee's name, email, and updated total — emitted from authController.ts, not from PointsService.
  2. 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 as if (referrer._id.equals(user._id)) return in the email and Google sign-up paths.

4. Points awarding

  1. The only caller that awards referral points is marketplaceController.ts, which invokes PointsService.processReferralReward(id) only when an order transitions to 'completed' (marketplaceController.ts:473-475, inside if (newStatus === 'completed')). It is NOT triggered on 'delivered', 'delivery', 'seller_paid', or any other status.
  2. PointsService.processReferralReward(purchaseRequestId) (:372-429):
    • Loads the purchase request, finds the buyer and the buyer's referredBy referrer (returns null if 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.activeReferrals as a count of ALL users with referredBy = referrer._id (:409-411) — this includes referrals that never purchased; it is not scoped to converted referrals.
    • Increments referrer.referralStats.totalEarned.
    • Emits referral-reward to user-{referrerId} (:417).
  3. Inside addPoints (:36-113):
    • Transaction-scoped Mongo session.
    • user.points.total += amount; user.points.available += amount.
    • PointTransaction.create({ type:'earn', source, amount, balance, metadata }). For source === 'referral', metadata.commission is set to the amount.
    • updateUserLevel(userId, session) recomputes the user's tier from LevelConfig.
    • Emits level-up on user-{userId} if the level changed (:91-99).
  4. 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

  1. Users see their balance under /dashboard/points and can spend via POST /api/points/redeem (applied as a discount against a specific purchase request).
  2. redeemPoints(userId, pointsToUse, purchaseRequestId) (:118-167):
    • Requires both purchaseRequestId and pointsToUse (controller returns 400 if either is missing or pointsToUse <= 0).
    • Throws Insufficient points if user.points.available < pointsToUse.
    • Decrements available, increments used, and records a PointTransaction with type: 'spend', source: 'redemption'.
    • The controller computes discount = pointsToUse * 1000 (1 point = 1000 IRR, always) and returns { transaction, discount, remainingPoints }. There are no amount / purpose / newBalance / redemption fields in the response.

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 in pointsRoutes.ts:8. None of these endpoints — including GET /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/transactionstype filter only accepts earn, spend, or expire (PointsService.ts:250-265). There is no source-based filtering: you cannot filter by referral / purchase / admin / redemption.
  • GET /api/points/leaderboard — the period filter (all/month/week) does not exist and is silently ignored. getLeaderboard(limit) only honors limit and always returns all-time data sorted by totalReferrals then totalEarned (PointsService.ts:434-479).
  • POST /api/points/admin/add reads { userId, amount, description } (the field is description, not reason). However the description is read but never persisted — the controller calls addPoints(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 from getTransactionDescription.

Database writes

  • users: referralCode on generation/rotation, referredBy on referee creation, referralStats.{totalReferrals, activeReferrals, totalEarned} and points.{total, available, used, level} on point events. activeReferrals is set by PointsService.processReferralReward (:409) as a count of all users with referredBy = referrer._id, regardless of purchase history.
  • pointtransactions: one document per earn / spend event. (expire is defined in the schema but never written — see below.)
  • levelconfigs: read-only at runtime (seeded at deploy).

Socket events emitted

  • referral-signupuser-{referrerId} on referee creation — emitted by authController.ts; this is an auth-domain event (NOT emitted by PointsService).
  • referral-rewarduser-{referrerId} when PointsService.processReferralReward runs — emitted by PointsService.ts:417; this is the points-domain event. (There is no referral-signup emitted from PointsService.)
  • level-upuser-{userId} when crossing a tier (PointsService.ts:92).

Side effects

  • points.available is the spendable balance; points.total is the lifetime accumulation (used for level tiers); points.used tracks 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 generateReferralCode is a hard guarantee.
  • Self-referralNOT blocked at any level (see danger callout above). Known gap.
  • Code rotation on regenerate — calling generate-referral-code again replaces the existing code, breaking previously shared links. There is no opt-out.
  • Referrer deletedreferredBy still points to the deleted user; the new user is effectively un-attributed.
  • Point expiry never enforced — the expiresAt field 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 creates expire-type transactions. Points never actually expire today.
  • activeReferrals semantics — 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 totalReferrals is incremented on sign-up and activeReferrals counts all referees regardless of purchase; neither distinguishes converted referrals. Consider a dedicated convertedReferrals counter incremented only inside processReferralReward.

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 via getUserPoints).
  • 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

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-signup emit 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 from NEXT_PUBLIC_API_URL)
  • Frontend: frontend/src/actions/points.ts (action layer; several actions have no UI callers)