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

211 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Referral Flow
tags: [flow, referral, points, growth]
related_models: ["[[User]]", "[[PointTransaction]]", "[[LevelConfig]]"]
related_apis: ["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](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
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 at `backend/src/routes/pointsRoutes.ts`.
- **MongoDB** — `users` (`referralCode`, `referredBy`, `referralStats`, `points`), `pointtransactions`, `levelconfigs`.
- **Socket.IO** — `referral-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
5. 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}`.
6. The sign-up form reads `?ref=` and pre-fills the referral field (hidden or visible).
### 3. Attribution at sign-up
7. 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.
8. 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
9. 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.
10. `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`).
11. 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`).
12. 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
13. Users see their balance under `/dashboard/points` and can spend via `POST /api/points/redeem` (applied as a discount against a specific purchase request).
14. `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
```mermaid
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/transactions``type` 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-signup`** → `user-{referrerId}` on referee creation — emitted by `authController.ts`; this is an **auth-domain** event (NOT emitted by `PointsService`).
- **`referral-reward`** → `user-{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-up`** → `user-{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-referral** — **NOT 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 deleted** — `referredBy` 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
- [[Registration Flow]] — attribution point.
- [[Google OAuth Flow]] — also supports `referralCode`.
- [[Notification Flow]] — `referral-signup`, `referral-reward`, `level-up` surface 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-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)