Skip to content

Mentor Earnings & Payouts

This domain governs how a mentor (an EXPERT-role user) earns money for completed 1:1 mentorship sessions, how those earnings accrue into a running balance, and how the mentor withdraws that balance through an admin-processed, optionally Razorpay-backed payout. It owns the mentor_earnings ledger record and the mentor_transactions append-only journal, and it is invoked both automatically (when a meeting completes through SlotCompletionService) and manually (when an admin credits a meeting or processes a withdrawal).

Status: documented from source on this branch.


Overview

Mr. Mentor runs a token economy (see token-economy.md if present, otherwise the payments/finance doc): students spend tokens to book mentor sessions, and 1 token = ₹TOKEN_VALUE (default ₹350, overridable via the TOKEN_VALUE env var — note: code comments say "₹300" but the constant defaults to 350). When a session completes successfully, the mentor earns the rupee value of the tokens consumed by that session. Earnings build up into an availableBalance the mentor can withdraw.

Personas / roles involved:

Persona Role What they do
Mentor EXPERT Views earnings summary + payment history, requests withdrawals.
Admin / Superadmin ADMIN / SUPERADMIN Manually credits meeting earnings, reviews withdrawal requests, creates Razorpay payout orders, marks withdrawals complete, inspects any mentor's earnings.
System (automatic) n/a SlotCompletionService credits earnings the moment a meeting is validly completed.

Where it sits in the suite: earnings are the downstream consumer of the mentorship-and-meetings flow. A booking → meeting → completion pipeline produces the "completed slot" event that triggers accrual. Payouts are the bridge to finance: the actual money movement reuses the same Razorpay credentials as the payments/GST domain. See mentorship-and-meetings.md and payments-finance-gst.md.

Source map:

  • Entities: src/entities/MentorEarnings.ts (defines both MentorEarnings and MentorTransaction)
  • Service: src/services/MentorEarningsService.ts
  • Controller: src/controllers/mentorEarnings.controller.ts
  • Routes: src/routes/mentorEarnings.routes.ts (mounted at /api)
  • Accrual trigger: src/services/SlotCompletionService.ts
  • Participation gate: src/services/MeetingLogsService.ts (shouldCreditEarnings)
  • Pricing util: src/utils/mentorMultiplierUtils.ts
  • Token value constant: src/utils/tokenValue.ts

Key concepts & entities

Glossary

  • Earning — a positive MentorTransaction of type earning, created once per completed (and credit-eligible) slot. Adds to both totalEarnings and availableBalance.
  • Withdrawal — a negative MentorTransaction of type withdrawal. Created in requested state; immediately debits availableBalance (reserved), and on completion adds to withdrawnAmount.
  • Available balance — money the mentor can currently withdraw (availableBalance). Earnings raise it; requesting a withdrawal lowers it.
  • Total earnings — lifetime gross earned (totalEarnings), never reduced by withdrawals.
  • Withdrawn amount — lifetime sum actually paid out (withdrawnAmount), incremented only when a withdrawal is marked complete.
  • Token valueTOKEN_VALUE (default 350). earningAmount = tokensUsed * TOKEN_VALUE.
  • earningsCredited / earningsCreditedAt — idempotency flag + timestamp stored on the Slots row (src/entities/Slots.ts) to prevent crediting the same meeting twice.
  • Participation gate — earnings only accrue if both mentor and student joined AND each participated ≥ 15 minutes (MeetingLogsService.shouldCreditEarnings).
  • Mentor multiplier — a pricing/score formula in src/utils/mentorMultiplierUtils.ts used to derive a mentor session price from experience/company/college/rating/role/niche/interview scores. (See "External integrations / pricing util" — it is not referenced by the earnings accrual path in current code.)

Entities (both declared in src/entities/MentorEarnings.ts)

MentorEarnings — table mentor_earnings, one row per mentor (the running ledger):

Column Type Notes
id uuid (PK)
mentorId uuid (FK → User) one row per mentor
totalEarnings decimal(10,2) lifetime gross, default 0
availableBalance decimal(10,2) withdrawable, default 0
withdrawnAmount decimal(10,2) lifetime paid out, default 0
createdAt / updatedAt timestamp

MentorTransaction — table mentor_transactions, append-only journal:

Column Type Notes
id uuid (PK)
mentorId uuid (FK → User)
type enum TransactionType earning | withdrawal
amount decimal(10,2) positive for earnings, negative for withdrawals
status enum WithdrawalStatus (nullable) only set on withdrawals: requested | in_process | completed | cancelled
description varchar(500) human-readable summary
bankUPI varchar(255) mentor-supplied UPI id or bank detail (withdrawals)
transactionId varchar(255) external/admin ref (Razorpay/UTR ref, or ADMIN-<id>-<ts> for manual credit)
paymentMethod varchar(50) UPI or Bank
slotId uuid (nullable) links an earning to its meeting; also the idempotency key
completedAt timestamp (nullable) set when a withdrawal is completed

There is no DB-level unique constraint on (slotId, type). Duplicate-prevention is enforced in application code (addEarnings / creditMeetingEarnings look up an existing earning transaction for the slot, and the Slots.earningsCredited boolean flag is checked first).


Architecture

flowchart TD
  subgraph Clients["Clients"]
    MFE["Mentor Frontend"]
    AFE["Admin Dashboard"]
    SOCK["Socket.IO meeting room"]
  end

  subgraph Routes["Routes mounted at /api"]
    R1["/mentor/earnings, /mentor/withdraw, /mentor/payment-history"]
    R2["/mentor/withdrawal-requests, /mentor/complete-withdrawal, /mentor/withdrawal-order"]
    R3["/mentor/admin/credit-earnings, /mentor/admin/meeting-earnings-info/:slotId"]
    R4["/admin/mentors/:mentorId/earnings, /admin/mentors/:mentorId/payment-history"]
    R5["/api/slots/:slotId/complete"]
  end

  CTRL["MentorEarningsController"]
  SCTRL["SlotCompletionController"]
  SVC["MentorEarningsService"]
  SCSVC["SlotCompletionService"]
  MLS["MeetingLogsService participation gate"]

  subgraph DB["PostgreSQL"]
    E1["mentor_earnings"]
    E2["mentor_transactions"]
    E3["slots earningsCredited"]
    E4["tokens balance"]
  end

  RZP["Razorpay Orders API"]

  MFE --> R1
  AFE --> R2
  AFE --> R3
  AFE --> R4
  MFE --> R5
  SOCK --> SCSVC
  R1 --> CTRL
  R2 --> CTRL
  R3 --> CTRL
  R4 --> CTRL
  R5 --> SCTRL
  CTRL --> SVC
  SCTRL --> SCSVC
  SCSVC --> MLS
  SCSVC --> SVC
  SVC --> E1
  SVC --> E2
  SVC --> E4
  SCSVC --> E3
  SVC --> RZP

Data model

erDiagram
  USER ||--o| MENTOR_EARNINGS : "has ledger"
  USER ||--o{ MENTOR_TRANSACTION : "has journal"
  SLOTS ||--o| MENTOR_TRANSACTION : "earning for"
  USER ||--o| TOKENS : "balance"

  MENTOR_EARNINGS {
    uuid id PK
    uuid mentorId FK
    decimal totalEarnings
    decimal availableBalance
    decimal withdrawnAmount
    timestamp createdAt
    timestamp updatedAt
  }

  MENTOR_TRANSACTION {
    uuid id PK
    uuid mentorId FK
    enum type "earning or withdrawal"
    decimal amount "negative for withdrawal"
    enum status "requested in_process completed cancelled"
    string description
    string bankUPI
    string transactionId
    string paymentMethod "UPI or Bank"
    uuid slotId FK
    timestamp completedAt
  }

  SLOTS {
    uuid id PK
    uuid mentorId FK
    uuid userId FK
    string status
    int durationMinutes
    boolean earningsCredited
    timestamp earningsCreditedAt
  }

  TOKENS {
    uuid id PK
    uuid userId FK
    int token
  }

Enums (from src/entities/MentorEarnings.ts):

  • TransactionType = earning | withdrawal
  • WithdrawalStatus = requested | in_process | completed | cancelled

Note: in_process and cancelled exist in the enum but the current service code only ever sets requested (on request) and completed (on completion). There is no code path that sets in_process or cancelled (inferred — no setter found in MentorEarningsService).


API surface

All routes live in src/routes/mentorEarnings.routes.ts and are mounted under /api (src/routes/index.ts: this.router.use('/api', this.mentorEarningsRoutes.router)). The slot-completion endpoint comes from src/routes/slotCompletion.routes.ts, also mounted at /api.

Method Path Auth/role Purpose
GET /api/mentor/earnings authMiddleware (the caller, as mentor) Earnings summary for the logged-in mentor: total, available, withdrawn, last transaction.
POST /api/mentor/withdraw authMiddleware Mentor requests a withdrawal (body: amount, paymentMethod, bankUPI).
GET /api/mentor/payment-history authMiddleware Paginated transaction history for the logged-in mentor (page, limit).
GET /api/mentor/withdrawal-requests authMiddleware + adminMiddleware List all withdrawal transactions, optional ?status=.
POST /api/mentor/complete-withdrawal authMiddleware + adminMiddleware Mark a withdrawal complete (body: transactionId, transactionRefId).
POST /api/mentor/withdrawal-order authMiddleware + adminMiddleware Create a Razorpay order for a pending withdrawal (body: transactionId).
POST /api/mentor/admin/credit-earnings authMiddleware + adminMiddleware Manually credit earnings for a completed slot (body: slotId).
GET /api/mentor/admin/meeting-earnings-info/:slotId authMiddleware + adminMiddleware Preview: can this slot be credited, how many tokens, estimated amount.
GET /api/admin/mentors/:mentorId/earnings authMiddleware + adminMiddleware Admin views any mentor's earnings summary.
GET /api/admin/mentors/:mentorId/payment-history authMiddleware + adminMiddleware Admin views any mentor's paginated history.
POST /api/slots/:slotId/complete authMiddleware (src/routes/slotCompletion.routes.ts) Complete a meeting; triggers automatic accrual (see journeys).

Auth nuance: the mentor-facing endpoints only run authMiddleware — they do not assert the caller is an EXPERT. They operate on req.user.id, so a non-mentor caller would simply get an empty/zeroed earnings record (a fresh mentor_earnings row is auto-created). Role gating is enforced only on the admin endpoints via adminMiddleware (ADMIN | SUPERADMIN).


User journeys

Journey 1 — Earnings accrue automatically when a meeting completes (happy path)

When a meeting ends (the Socket.IO room signals completion, or the mentor frontend calls the complete endpoint), SlotCompletionService.completeMeeting runs. It computes the real duration, applies the participation gate, and — if eligible — credits the mentor through MentorEarningsService.addEarnings.

sequenceDiagram
  participant Room as Meeting room or API
  participant SC as SlotCompletionService
  participant MLS as MeetingLogsService
  participant ES as MentorEarningsService
  participant DB as PostgreSQL

  Room->>SC: completeMeeting slotId and endTime
  SC->>DB: load slot with tokenUsage
  alt slot already completed and earnings credited
    SC-->>Room: already credited, no double pay
  else proceed
    SC->>SC: set status COMPLETED and compute durationMinutes
    SC->>DB: fire and forget grant student XP for mentor_call_completed
    alt slot isFree
      SC->>DB: save slot
      SC-->>Room: free meeting, no earnings
    else paid
      SC->>DB: find tokenUsage for slot
      alt no tokenUsage row
        SC-->>Room: completed but earnings not credited
      else has tokenUsage
        SC->>MLS: shouldCreditEarnings slotId
        MLS-->>SC: shouldCredit true or false with reason
        alt shouldCredit is false
          SC->>DB: save slot only
          SC-->>Room: completed but earnings withheld with reason
        else shouldCredit is true
          SC->>ES: addEarnings mentorId tokensUsed slotId duration
          ES->>DB: check existing earning for slot
          ES->>DB: bump totalEarnings and availableBalance
          ES->>DB: insert earning transaction
          ES->>DB: add tokensUsed to mentor token balance
          ES-->>SC: success with amount
          SC->>DB: set earningsCredited true and earningsCreditedAt
          SC-->>Room: earnings credited to mentor
        end
      end
    end
  end

Key rules in this journey:

  • Free meetings (slot.isFree) skip earnings entirely.
  • No token usage recorded → no earnings (returns a non-success message but still saves the slot as completed).
  • Participation gate (MeetingLogsService.shouldCreditEarnings): both parties must have joined, and each must have participated ≥ 15 minutes (MIN_DURATION_SECONDS = 15 * 60). Otherwise earnings are withheld with a reason.
  • Amount = tokensUsed * TOKEN_VALUE. tokensUsed comes from the slot's tokenUsage.tokensUsed (defaults to 1).
  • Crediting also adds the earned tokens to the mentor's own tokens balance (tokenRepository), creating the row if missing.

Journey 2 — Mentor views earnings dashboard

sequenceDiagram
  participant M as Mentor Frontend
  participant API as Express API
  participant ES as MentorEarningsService
  participant DB as PostgreSQL

  M->>API: GET /api/mentor/earnings with JWT
  API->>ES: getMentorEarnings userId
  ES->>DB: find mentor_earnings by mentorId
  alt no ledger row yet
    ES->>DB: create zeroed mentor_earnings row
  end
  ES->>DB: find latest transaction ordered by createdAt desc
  ES-->>API: total available withdrawn and lastTransaction
  API-->>M: 200 with earnings summary

  M->>API: GET /api/mentor/payment-history page and limit
  API->>ES: getPaymentHistory userId page limit
  ES->>DB: findAndCount transactions by mentorId paginated
  ES-->>API: transactions total currentPage totalPages
  API-->>M: 200 with paginated history

Journey 3 — Mentor requests a withdrawal

The mentor submits an amount plus a payout destination. The service checks the available balance, records a negative withdrawal transaction in requested state, and immediately debits availableBalance (reserving the funds). Nothing is paid yet.

sequenceDiagram
  participant M as Mentor Frontend
  participant API as Express API
  participant ES as MentorEarningsService
  participant DB as PostgreSQL

  M->>API: POST /api/mentor/withdraw amount paymentMethod bankUPI
  API->>API: validate amount and paymentMethod is UPI or Bank
  alt missing fields or bad method
    API-->>M: 400 validation error
  else valid
    API->>ES: requestWithdrawal userId amount method bankUPI
    ES->>DB: load or create mentor_earnings
    alt availableBalance less than amount
      ES-->>API: insufficient balance
      API-->>M: 400 insufficient balance
    else enough balance
      ES->>DB: insert withdrawal transaction negative amount status requested
      ES->>DB: subtract amount from availableBalance
      ES-->>API: success with transaction
      API-->>M: 200 withdrawal request submitted
    end
  end

Reservation semantics: the balance is debited at request time, not at completion. If a request were ever cancelled, the code does not restore the balance (no cancellation path exists — see gotchas).

Journey 4 — Admin processes a payout (with Razorpay)

Admins review pending requests, optionally open a Razorpay order/checkout to actually move money, then mark the withdrawal complete with an external reference id. Completion increments lifetime withdrawnAmount (the available balance was already debited at request time).

sequenceDiagram
  participant A as Admin Dashboard
  participant API as Express API
  participant ES as MentorEarningsService
  participant RZP as Razorpay
  participant DB as PostgreSQL

  A->>API: GET /api/mentor/withdrawal-requests status requested
  API->>ES: getWithdrawalRequests status
  ES->>DB: find withdrawal transactions with mentor relation
  ES-->>API: list of requests
  API-->>A: 200 pending requests

  opt pay via Razorpay
    A->>API: POST /api/mentor/withdrawal-order transactionId
    API->>ES: createWithdrawalOrder transactionId
    alt razorpay not configured
      ES-->>API: throws not configured
      API-->>A: 500 razorpay missing
    else configured
      ES->>DB: load withdrawal transaction
      alt not a withdrawal or not in requested state
        ES-->>API: throws invalid state
        API-->>A: 500 invalid withdrawal
      else valid
        ES->>RZP: orders.create amount in paise receipt notes
        RZP-->>ES: razorpay order id and status
        ES-->>API: order and keyId and transaction
        API-->>A: 201 order created for checkout
      end
    end
  end

  A->>API: POST /api/mentor/complete-withdrawal transactionId transactionRefId
  API->>ES: completeWithdrawal transactionId refId
  ES->>DB: load withdrawal transaction
  ES->>DB: set status completed and transactionId refId and completedAt now
  ES->>DB: add abs amount to withdrawnAmount on ledger
  ES-->>API: completed transaction
  API-->>A: 200 withdrawal completed

Journey 5 — Admin manually credits a completed meeting

When automatic accrual did not run (e.g. completion happened outside the socket flow, or earnings were withheld and an admin overrides), an admin can credit a specific slot. This path lives in creditMeetingEarnings and is stricter than addEarnings: it re-validates that the slot is COMPLETED and not already credited.

sequenceDiagram
  participant A as Admin Dashboard
  participant API as Express API
  participant ES as MentorEarningsService
  participant DB as PostgreSQL

  A->>API: GET /api/mentor/admin/meeting-earnings-info/:slotId
  API->>ES: getMeetingEarningsInfo slotId
  ES->>DB: load slot with tokenUsage
  ES-->>API: canCredit alreadyCredited tokensUsed estimatedAmount reason
  API-->>A: 200 preview

  A->>API: POST /api/mentor/admin/credit-earnings slotId
  API->>ES: creditMeetingEarnings slotId adminId
  ES->>DB: load slot with mentor user and tokenUsage
  alt slot missing or not COMPLETED
    ES-->>API: cannot credit with reason
    API-->>A: 400 not eligible
  else eligible
    alt slot earningsCredited or earning transaction exists
      ES-->>API: already credited
      API-->>A: 400 already credited
    else fresh credit
      ES->>DB: sum tokensUsed from tokenUsage default 1
      ES->>DB: bump totalEarnings and availableBalance
      ES->>DB: insert earning transaction tagged ADMIN adminId timestamp
      ES->>DB: set slot earningsCredited true and earningsCreditedAt
      ES->>DB: add tokens to mentor token balance
      ES-->>API: success amount and earnings
      API-->>A: 200 credited
    end
  end

Background jobs & async

This domain has no dedicated BullMQ queue or worker and no cron schedule. Accrual is synchronous inside the request/socket handler. The relevant async surfaces are:

  • Socket.IO completion triggerssrc/socket.ts calls slotCompletionService.completeMeeting(...) (around lines 89 and 654) when a meeting ends in the room, which is the primary automatic accrual path.
  • Fire-and-forget student XP — on completion, SlotCompletionService.creditStudentForCompletedSlot grants the student mentor_call_completed XP and evaluates badges in a non-blocking async IIFE; failures there are logged but never block earnings.
  • HTTP completionPOST /api/slots/:slotId/complete (src/controllers/slotCompletion.controller.ts) is the synchronous fallback to the socket path.

There is no message queue, webhook, or retry mechanism specific to payouts: a failed Razorpay order or a half-applied write is not automatically retried.


External integrations

Razorpay (src/services/MentorEarningsService.ts)

  • Initialized lazily in the service constructor from RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET.
  • If either env var is missing, this.razorpay is null and createWithdrawalOrder throws "Razorpay is not configured..." — i.e. the payout-order feature self-disables but earnings, withdrawal requests, and manual completion still work (an admin can complete a withdrawal with any external reference id without creating a Razorpay order).
  • orders.create is called with amount in paise (amountInRupees * 100), currency INR, a receipt wd_<transactionId>, and notes carrying withdrawalTransactionId, mentorId, paymentMethod.
  • These are the same Razorpay credentials used by the payments/finance domain — see payments-finance-gst.md.

Important: creating a Razorpay order does not itself move money to the mentor, and the code does not verify a Razorpay payment signature before completing the withdrawal. completeWithdrawal simply records whatever transactionRefId the admin supplies. The actual payout is an out-of-band/manual step (inferred — there is no Razorpay Payouts/RazorpayX call in this service, only orders.create).

Pricing util — src/utils/mentorMultiplierUtils.ts

A self-contained scoring/pricing helper that computes a mentorMultiplier and a session finalPrice from a mentor's attributes:

  • Sub-scores: experience (weight 0.4), company tier (0.25), college tier (0.05), rating (0.15), role (0.05), niche skills (0.05), interview experience (0.05).
  • Multiplier M = min(1 + 0.15 * totalWeightedScore, 2.5) (capped at 2.5).
  • finalPrice = round(500 * M) where 500 is the base rate.

This produces a per-session price for a mentor, distinct from the accrual amount (which is strictly tokensUsed * TOKEN_VALUE). calculateMentorMultiplier is not imported by the earnings service or accrual path in current code (grep shows it is only defined in its own file), so it is best read as mentor onboarding/pricing logic adjacent to this domain rather than part of the payout pipeline.

Token value — src/utils/tokenValue.ts

TOKEN_VALUE = process.env.TOKEN_VALUE ? parseInt(...) : 350. Both addEarnings and creditMeetingEarnings multiply tokens by this constant. (Code comments saying 1 token = ₹300 are stale; the default is 350.)


Status lifecycles

Withdrawal (MentorTransaction of type withdrawal)

stateDiagram-v2
  [*] --> requested : mentor requests withdrawal, availableBalance debited
  requested --> completed : admin completeWithdrawal, withdrawnAmount incremented
  requested --> in_process : enum value exists, no code setter
  requested --> cancelled : enum value exists, no code setter
  completed --> [*]
  in_process --> completed : enum value exists, no code setter

Only the requested → completed transition is actually implemented. in_process and cancelled are declared in WithdrawalStatus but never assigned by MentorEarningsService (inferred from source).

Earning accrual (per slot)

stateDiagram-v2
  [*] --> NotCredited : slot earningsCredited false
  NotCredited --> Withheld : free meeting or no tokenUsage or participation gate fails
  NotCredited --> Credited : eligible, addEarnings or creditMeetingEarnings runs
  Withheld --> Credited : admin manual credit if slot becomes eligible
  Credited --> Credited : repeat attempts blocked by earningsCredited flag and slot earning lookup
  Credited --> [*]

Edge cases, limits & gotchas

  • Idempotency is application-level, not DB-level. There is no unique constraint on mentor_transactions (slotId, type). Duplicate crediting is prevented by (a) the Slots.earningsCredited boolean and (b) a lookup for an existing earning transaction on the slot. A race between two concurrent completions could theoretically slip past these checks since they are not transactional.
  • Writes are not wrapped in a DB transaction. addEarnings / creditMeetingEarnings / requestWithdrawal / completeWithdrawal perform several sequential save calls. A crash mid-sequence can leave the ledger and the journal inconsistent (e.g. transaction inserted but balance not updated).
  • Balance reserved at request time, never refunded. requestWithdrawal debits availableBalance immediately. There is no cancellation/rejection path that restores it, so a stuck requested withdrawal permanently locks that amount unless manually fixed.
  • No EXPERT role check on mentor endpoints. /api/mentor/earnings, /withdraw, /payment-history only require a valid JWT. Any authenticated user hitting them gets/creates their own (zeroed) ledger.
  • completeWithdrawal does not re-check status. It will set status = completed and bump withdrawnAmount even if the withdrawal was already completed — calling it twice double-counts the payout in withdrawnAmount (the available balance is untouched here, but the lifetime metric drifts).
  • Razorpay order is informational. Creating the order does not pay the mentor and no payment signature is verified before completion; the admin is trusted to enter a correct transactionRefId.
  • Token side-effect. Crediting earnings also increments the mentor's own tokens.token balance by the same token count — mentors accumulate spendable tokens as a by-product of earning. (inferred intent)
  • Participation threshold is hardcoded at 15 minutes per party in MeetingLogsService.shouldCreditEarnings (MIN_DURATION_SECONDS = 15 * 60); not configurable via env.
  • TOKEN_VALUE mismatch in comments. Several doc-comments say "₹300"; the actual constant defaults to
  • Always trust src/utils/tokenValue.ts.
  • Decimal columns return strings. TypeORM returns decimal columns as strings; the service consistently wraps reads in Number(...) before arithmetic — preserve this when extending the code or balances corrupt via string concatenation.
  • Multi-platform / x-platform. This domain does not branch on the x-platform header; earnings are keyed purely on mentorId.