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 bothMentorEarningsandMentorTransaction) - 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
MentorTransactionof typeearning, created once per completed (and credit-eligible) slot. Adds to bothtotalEarningsandavailableBalance. - Withdrawal — a negative
MentorTransactionof typewithdrawal. Created inrequestedstate; immediately debitsavailableBalance(reserved), and on completion adds towithdrawnAmount. - 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 value —
TOKEN_VALUE(default 350).earningAmount = tokensUsed * TOKEN_VALUE. - earningsCredited / earningsCreditedAt — idempotency flag + timestamp stored on the
Slotsrow (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.tsused 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/creditMeetingEarningslook up an existing earning transaction for the slot, and theSlots.earningsCreditedboolean 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|withdrawalWithdrawalStatus=requested|in_process|completed|cancelled
Note:
in_processandcancelledexist in the enum but the current service code only ever setsrequested(on request) andcompleted(on completion). There is no code path that setsin_processorcancelled(inferred — no setter found inMentorEarningsService).
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 anEXPERT. They operate onreq.user.id, so a non-mentor caller would simply get an empty/zeroed earnings record (a freshmentor_earningsrow is auto-created). Role gating is enforced only on the admin endpoints viaadminMiddleware(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.tokensUsedcomes from the slot'stokenUsage.tokensUsed(defaults to 1). - Crediting also adds the earned tokens to the mentor's own
tokensbalance (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 triggers —
src/socket.tscallsslotCompletionService.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.creditStudentForCompletedSlotgrants the studentmentor_call_completedXP and evaluates badges in a non-blocking async IIFE; failures there are logged but never block earnings. - HTTP completion —
POST /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_IDandRAZORPAY_KEY_SECRET. - If either env var is missing,
this.razorpayisnullandcreateWithdrawalOrderthrows"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.createis called withamountin paise (amountInRupees * 100),currencyINR, a receiptwd_<transactionId>, and notes carryingwithdrawalTransactionId,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.
completeWithdrawalsimply records whatevertransactionRefIdthe admin supplies. The actual payout is an out-of-band/manual step (inferred — there is no Razorpay Payouts/RazorpayX call in this service, onlyorders.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) theSlots.earningsCreditedboolean and (b) a lookup for an existingearningtransaction 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/completeWithdrawalperform several sequentialsavecalls. 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.
requestWithdrawaldebitsavailableBalanceimmediately. There is no cancellation/rejection path that restores it, so a stuckrequestedwithdrawal permanently locks that amount unless manually fixed. - No EXPERT role check on mentor endpoints.
/api/mentor/earnings,/withdraw,/payment-historyonly require a valid JWT. Any authenticated user hitting them gets/creates their own (zeroed) ledger. completeWithdrawaldoes not re-check status. It will setstatus = completedand bumpwithdrawnAmounteven if the withdrawal was already completed — calling it twice double-counts the payout inwithdrawnAmount(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.tokenbalance 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_VALUEmismatch in comments. Several doc-comments say "₹300"; the actual constant defaults to- Always trust
src/utils/tokenValue.ts. - Decimal columns return strings. TypeORM returns
decimalcolumns as strings; the service consistently wraps reads inNumber(...)before arithmetic — preserve this when extending the code or balances corrupt via string concatenation. - Multi-platform /
x-platform. This domain does not branch on thex-platformheader; earnings are keyed purely onmentorId.
Related docs¶
- mentorship-and-meetings.md — slots, bookings, meeting completion (the accrual trigger).
- payments-finance-gst.md — Razorpay payments, GST invoicing, finance reporting (shared Razorpay credentials).
- token-economy.md — tokens, token usage,
TOKEN_VALUE(if present in this tree). - ../architecture/socket-io-realtime.md — Socket.IO meeting lifecycle that fires
completeMeeting(if present).