Skip to content

Token Economy & Token Governance

The token economy is the internal currency that gates premium, mentor-time-consuming actions on the MAS / Mr. Mentor platform. A student spends 1 token to book a 1:1 mentor meeting and 1 resume token per resume review. Tokens enter a user's wallet through Razorpay purchases, promotional grants, or Batch-Lead-approved allotment requests; they leave it as meeting bookings and resume-review spends; and they return as automatic refunds when a meeting is cancelled or expires. Every balance change is mirrored into an append-only token_usage audit ledger. "Token Governance" is the Phase-1, feature-flagged overlay that adds a student-request → Batch-Lead-approval workflow on top of the wallet primitives.

Status: documented from source on this branch.


Overview

The domain answers four questions:

  • How many tokens do I have?GET /api/tokens/balance, computed from the persistent wallet plus a still-valid promotional grant.
  • How do I get more? — buy them via Razorpay (/api/tokens/purchase + /api/tokens/verify-payment), receive a promotional grant, or raise a governance allotment request that a Batch Lead approves.
  • How are they spent? — booking a mentor meeting (MentorService) or requesting a resume review (ResumeReviewService) atomically deducts a token and writes a ledger row.
  • What happens on cancellation? — the meeting flows (MentorService, SlotCleanupService) credit a REFUND token back.

Personas / roles (User.role enum: USER, ADMIN, SUPERADMIN, EXPERT, SALES; Batch Lead is a sales-side role resolved through batch assignment):

Persona What they do in this domain
Student / USER View balance, buy tokens, spend tokens on meetings & resume reviews, raise allotment requests, withdraw their own requests.
Mentor / EXPERT Indirectly trigger refunds by approving meeting cancellations.
Batch Lead Review (approve / reject) student token-allotment requests for batches they own.
Admin / Superadmin Issue manual refunds / adjustments (via AdminDashboardService), monitor purchases.
System (BullMQ) Auto-refund expired pending meetings (SlotCleanupService, cleanup queue).

Where it sits: the token economy is a horizontal service consumed by the mentorship meeting flow, the resume-review flow, and the finance/payments stack. It is owned entirely by mr-mentor-backend (TypeORM entities in the mas PostgreSQL database). Purchases bridge to Razorpay (see payments); invoice notifications bridge to WhatsApp.


Key concepts & entities

Concept Meaning
Wallet token Persistent, paid balance held on tokens.token (mock/meeting) and tokens.resumeToken (resume). Never auto-expires in practice (1-year expiresAt is set but not enforced on read).
Promotional token Time-boxed grant on promotional_tokens split into meetToken and resumeToken, with a legacy aggregate tokenValue. Counts toward balance only while expiredAt is in the future. Spent before wallet tokens.
Token hold The platform does not use a separate "hold" state. Booking debits immediately and sets the slot to UNDER_REVIEW; cancellation/expiry credits a refund. The "hold" is effectively the deducted-token-plus-pending-slot.
Token usage / ledger Append-only audit row in token_usage capturing usageType, tokensUsed, balanceBefore, balanceAfter, optional slotId / referenceId. The source of truth for reconciliation.
Allotment request Governance row in token_allotment_requests — a student asks for N mock + M resume tokens; a Batch Lead approves (credits promotional tokens) or rejects.
Resume-review request Governance row in resume_review_requests — spending 1 resume token creates a review record.
TOKEN_VALUE Rupee conversion constant (process.env.TOKEN_VALUE or default 350), used for mentor-earning valuation, not for purchase pricing.

Main TypeORM entities (all in src/entities/):

Entity Table File Role
Token tokens src/entities/Tokens.ts One-to-one wallet per user (token, resumeToken, expiresAt).
TokenUsage token_usage src/entities/TokenUsage.ts Append-only ledger; enum TokenUsageType.
TokenPurchase token_purchases src/entities/TokenPurchase.ts Razorpay order/invoice + payment status. Enum PaymentStatus.
PromotionalToken promotional_tokens src/entities/PromotionalToken.ts Time-boxed promo grant (meetToken, resumeToken, tokenValue, expiredAt).
TokenAllotmentRequest token_allotment_requests src/entities/TokenAllotmentRequest.ts Governance request + approval state. Enum TokenAllotmentRequestStatus.
ResumeReviewRequest resume_review_requests src/entities/ResumeReviewRequest.ts Resume-review spend record. Enum ResumeReviewRequestStatus.

Supporting services & utils:

  • src/services/TokenService.ts — wallet primitives (balance, purchase, verify, credit, debit, history).
  • src/services/TokenAllotmentRequestService.ts — governance request lifecycle + transactional approval.
  • src/services/ResumeReviewService.ts — transactional resume-token spend.
  • src/services/StudentTokenLedgerService.ts — derived per-student allotted/used snapshot.
  • src/utils/tokenValue.tsTOKEN_VALUE constant.
  • src/utils/featureFlags.ts — governance kill-switches.

Note: TokenUsageType.RESUME_REVIEW and resume tokens are also used by the Mr. Hire / resume-screening domain. This doc covers the wallet + governance mechanics; see recruitment for what a resume review does.


Architecture

flowchart TD
    subgraph FE["Frontends"]
        WEB["mas-website-live student portal"]
        ADMIN["mr-mentor-frontend admin"]
    end

    subgraph RT["Routes (mounted at /api)"]
        TR["token.routes.ts"]
        GR["tokenGovernance.routes.ts (feature-flagged)"]
    end

    subgraph CT["Controllers"]
        TC["TokenController"]
        AC["TokenAllotmentRequestController"]
        RC["ResumeReviewController"]
    end

    subgraph SV["Services"]
        TS["TokenService"]
        AS["TokenAllotmentRequestService"]
        RS["ResumeReviewService"]
        LS["StudentTokenLedgerService"]
        MS["MentorService (booking / refund)"]
        SC["SlotCleanupService (auto-refund)"]
    end

    subgraph DB["PostgreSQL (mas)"]
        T["tokens"]
        TU["token_usage"]
        TP["token_purchases"]
        PT["promotional_tokens"]
        TAR["token_allotment_requests"]
        RR["resume_review_requests"]
    end

    subgraph EXT["External"]
        RZP["Razorpay (orders / invoices)"]
        WA["WhatsApp invoice notify"]
        Q["BullMQ cleanup queue"]
    end

    WEB --> TR
    WEB --> GR
    ADMIN --> TR
    TR --> TC
    GR --> AC
    GR --> RC
    TC --> TS
    AC --> AS
    RC --> RS
    TS --> RZP
    TS --> WA
    TS --> T
    TS --> TU
    TS --> TP
    TS --> TP
    TS --> TP
    AS --> TAR
    AS --> PT
    AS --> TU
    RS --> RR
    RS --> PT
    RS --> T
    RS --> TU
    LS --> T
    LS --> PT
    LS --> TU
    MS --> T
    MS --> PT
    MS --> TU
    MS --> TS
    SC --> TS
    Q --> SC
    TS --> TP
    TP -.stores order/invoice ids.-> RZP

Data model

erDiagram
    USER ||--o| TOKEN : "has wallet"
    USER ||--o| PROMOTIONAL_TOKEN : "has promo grant"
    USER ||--o{ TOKEN_USAGE : "ledger rows"
    USER ||--o{ TOKEN_PURCHASE : "purchases"
    USER ||--o{ TOKEN_ALLOTMENT_REQUEST : "raises"
    USER ||--o{ RESUME_REVIEW_REQUEST : "raises"
    SLOTS ||--o{ TOKEN_USAGE : "referenced by"
    BATCH ||--o{ TOKEN_ALLOTMENT_REQUEST : "scoped to"

    TOKEN {
        uuid id PK
        float token
        float resumeToken
        uuid userId FK
        timestamp expiresAt
    }
    PROMOTIONAL_TOKEN {
        bigint id PK
        int tokenValue
        int meetToken
        int resumeToken
        string userId FK
        timestamp expiredAt
    }
    TOKEN_USAGE {
        uuid id PK
        uuid userId FK
        uuid slotId FK
        enum usageType
        int tokensUsed
        int balanceBefore
        int balanceAfter
        string referenceId
        text description
    }
    TOKEN_PURCHASE {
        uuid id PK
        uuid userId FK
        int tokenQuantity
        decimal amount
        string currency
        enum paymentStatus
        string razorpayOrderId
        string razorpayPaymentId
        string razorpayInvoiceId
        timestamp completedAt
    }
    TOKEN_ALLOTMENT_REQUEST {
        uuid id PK
        uuid studentId FK
        uuid batchId FK
        uuid requestedByUserId FK
        int mockTokens
        int resumeTokens
        text reason
        enum status
        uuid reviewedByBatchLeadId FK
        timestamp reviewedAt
        text reviewNotes
    }
    RESUME_REVIEW_REQUEST {
        uuid id PK
        uuid studentId FK
        string resumeUrl
        text notes
        enum status
    }

Notable enums / status fields:

  • PaymentStatus (token_purchases.paymentStatus): pending | completed | failed | cancelled.
  • TokenUsageType (token_usage.usageType): meeting_booking | resume_review | penalty | refund | bonus.
  • TokenAllotmentRequestStatus: pending | approved | rejected | withdrawn.
  • ResumeReviewRequestStatus: pending | under_review | completed | rejected.

Audit convention: token_usage.tokensUsed is stored as a positive magnitude for all types, including refund and bonus (credits). The direction is implied by usageType and by balanceBefore / balanceAfter. recordTokenUsage columns are typed int even though the wallet token/resumeToken columns are float (a gotcha — see below).


API surface

All token routes are mounted at /api (src/routes/index.tsthis.router.use('/api', this.tokenRoutes.router) and this.router.use('/api', this.tokenGovernanceRoutes.router)). Every endpoint requires a valid JWT via authMiddleware (req.user.id). Governance endpoints additionally short-circuit to HTTP 503 when their feature flag is OFF.

Core wallet — src/routes/token.routes.ts

Method Path Auth/role Purpose
GET /api/tokens/balance JWT Current spendable balance (wallet + valid promo).
POST /api/tokens/purchase JWT Create a TokenPurchase + Razorpay order/invoice.
POST /api/tokens/verify-payment JWT Verify Razorpay signature, mark completed, credit wallet.
GET /api/tokens/history JWT This user's purchase history (TokenPurchase[]).
GET /api/tokens/usage-history JWT This user's ledger (TokenUsage[], with slot relation).
DELETE /api/tokens/purchase/:purchaseId JWT Cancel a still-pending purchase.
POST /api/tokens/deduct JWT Direct wallet debit (no usage row recorded — see gotchas).

Governance — src/routes/tokenGovernance.routes.ts

Method Path Auth/role Flag Purpose
POST /api/student/token-requests JWT ENABLE_TOKEN_REQUEST_FLOW Student raises a token-allotment request.
GET /api/student/token-requests JWT ENABLE_TOKEN_REQUEST_FLOW List own requests.
DELETE /api/student/token-requests/:id JWT ENABLE_TOKEN_REQUEST_FLOW Withdraw own pending request.
GET /api/batchlead/token-requests JWT + batchLeadMiddleware ENABLE_TOKEN_REQUEST_FLOW List requests for the BL's batches (?status=pending\|approved\|rejected\|withdrawn\|all).
PATCH /api/batchlead/token-requests/:id JWT + batchLeadMiddleware ENABLE_TOKEN_REQUEST_FLOW Approve / reject a request.
POST /api/resume-reviews JWT ENABLE_RESUME_REVIEW_SPEND Spend 1 resume token, create a review.
GET /api/resume-reviews JWT ENABLE_RESUME_REVIEW_SPEND List own resume-review requests.
GET /api/student/batch-meetings JWT (no token flag) Read-only batch-meeting visibility (co-located in this router).

Booking and refunds have no dedicated token endpoint — they are side effects of the meeting flow (MentorService.requestMeeting, approveMeetingCancellation) and the cleanup worker. The POST /api/tokens/deduct endpoint exists but is a thin wrapper around deductTokensFromUser with no options, so it does not write a ledger row; the real booking path bypasses it.


User journeys

1. View token balance

getUserTokenBalance sums the wallet token and, if a promo grant is still valid, its meeting tokens. Expired promotional tokens contribute zero.

sequenceDiagram
    participant FE as Frontend
    participant API as TokenController
    participant TS as TokenService
    participant DB as PostgreSQL

    FE->>API: GET /api/tokens/balance with JWT
    API->>TS: getUserTokenBalance for userId
    TS->>DB: find tokens row for user
    TS->>DB: find promotional_tokens row for user
    DB-->>TS: wallet and promo rows
    Note over TS: promo counts only if expiredAt is in the future
    TS-->>API: wallet.token plus valid promo meetToken
    API-->>FE: 200 with tokenBalance and userId

2. Purchase tokens (Razorpay)

A two-step flow: create order then verify. The service prefers the Razorpay Invoices API so the student gets an official emailed tax invoice, and falls back to orders.create when invoicing is unavailable (missing email, feature disabled). Detailed payment mechanics live in payments; here is the token-side view.

sequenceDiagram
    participant FE as Frontend
    participant API as TokenController
    participant TS as TokenService
    participant RZP as Razorpay
    participant DB as PostgreSQL

    FE->>API: POST /api/tokens/purchase with quantity and amount
    API->>TS: createTokenPurchase
    TS->>DB: insert token_purchases status pending
    TS->>RZP: invoices.create with customer and line item
    alt invoice succeeds
        RZP-->>TS: invoice with order_id and short_url
        TS->>DB: save razorpayOrderId invoiceId invoiceUrl
    else invoice fails or no email
        TS->>RZP: orders.create fallback
        RZP-->>TS: order id
        TS->>DB: save razorpayOrderId
    end
    TS-->>API: tokenPurchase and razorpayOrder
    API-->>FE: 201 with order and razorpayKeyId

    Note over FE,RZP: student completes Razorpay Checkout

    FE->>API: POST /api/tokens/verify-payment with paymentId and signature
    API->>TS: verifyAndCompletePayment
    TS->>DB: find token_purchases by order or invoice or latest pending
    alt already completed
        TS-->>API: error already completed
        API-->>FE: 400 payment already completed
    else signature mismatch
        Note over TS: tries order and invoice HMAC formulas
        TS-->>API: error invalid signature
        API-->>FE: 400 invalid signature
    else verified
        TS->>DB: set paymentStatus completed and completedAt
        TS->>TS: addTokensToUser credits wallet
        TS->>DB: upsert tokens row new balance
        TS->>RZP: send invoice WhatsApp best effort
        TS-->>API: success with tokenPurchase
        API-->>FE: 200 with new balance
    end

Key alternates: duplicate verify is rejected with "Payment already completed"; an invalid HMAC across all candidate formulas returns "Invalid signature"; the WhatsApp invoice notify is wrapped in try/catch and never blocks the credit. addTokensToUser here is called without an options object, so a successful purchase credit does not write a token_usage row (only refunds/bonuses do).

3. Spend a token to book a mentor meeting

Booking is the canonical spend. It runs inside a single DB transaction in MentorService.requestMeeting, preferring promotional tokens, then wallet tokens. There is no separate "hold" record — the token is debited immediately and the slot moves to UNDER_REVIEW.

sequenceDiagram
    participant FE as Frontend
    participant MS as MentorService
    participant TXN as DB Transaction
    participant MAIL as Email queue

    FE->>MS: requestMeeting with mentorId and slot time
    MS->>TXN: start transaction
    TXN->>TXN: lock promotional_tokens for student
    alt valid promo meetToken at least 1
        TXN->>TXN: set slot UNDER_REVIEW and assign student
        TXN->>TXN: decrement promo meetToken and sync tokenValue
        TXN->>TXN: insert token_usage meeting_booking magnitude 1
        TXN->>TXN: decrement mentor slotsLeft
        TXN-->>MS: commit
    else fall back to wallet
        TXN->>TXN: read tokens row for student
        alt wallet token below 1
            TXN-->>MS: rollback Insufficient tokens
            MS-->>FE: error Insufficient tokens to book this slot
        else wallet token at least 1
            TXN->>TXN: set slot UNDER_REVIEW and assign student
            TXN->>TXN: decrement wallet token
            TXN->>TXN: insert token_usage meeting_booking magnitude 1
            TXN->>TXN: decrement mentor slotsLeft
            TXN-->>MS: commit
        end
    end
    MS->>MAIL: queue meeting approval email to mentor
    MS-->>FE: saved slot UNDER_REVIEW

4. Refund a token on cancellation or expiry

Three paths credit a REFUND token, all via TokenService.addTokensToUser(..., { usageType: REFUND }), which both bumps the wallet and writes a ledger row:

  • Mentor approves a student's cancellation request — MentorService.approveMeetingCancellation.
  • Admin-driven cancellation — MentorService cancellation path / AdminDashboardService.
  • A pending meeting request expires unattended — SlotCleanupService (cleanup queue, every 24h).
sequenceDiagram
    participant ACT as Mentor or Cleanup worker
    participant MS as MentorService or SlotCleanupService
    participant TS as TokenService
    participant DB as PostgreSQL

    ACT->>MS: approve cancellation or detect expired pending slot
    MS->>MS: verify slot state cancellable or expired
    MS->>TS: addTokensToUser student plus 1 usageType refund
    TS->>DB: upsert tokens row balance plus 1
    TS->>DB: insert token_usage refund magnitude 1 with slotId
    MS->>DB: set slot CANCELLED and recreate available slot
    MS-->>ACT: refund issued and slot freed

Note: the cleanup-worker refund calls TokenService outside the slot transaction, which the source flags as not fully transaction-safe (best-effort).

5. Promotional token grant

Promotional tokens are written directly to promotional_tokens (by seeding, admin tooling, or governance approval). They are split into meetToken and resumeToken, with tokenValue kept as the legacy aggregate. They only count toward balance while expiredAt is in the future and are always spent before wallet tokens.

sequenceDiagram
    participant GRANT as Admin or governance or seed
    participant PT as promotional_tokens
    participant TS as TokenService balance read

    GRANT->>PT: upsert meetToken and resumeToken and expiredAt
    Note over PT: tokenValue equals meetToken plus resumeToken
    TS->>PT: read promo on next balance check
    alt expiredAt in future
        TS-->>TS: add meetToken to spendable balance
    else expired
        TS-->>TS: ignore promo contributes zero
    end

6. Governance — allotment request, approval, credit

The headline governance journey. A student raises a request for mock + resume tokens with a reason; the Batch Lead for the student's batch approves or rejects. Approval is fully transactional with pessimistic row locks and gated behind onboarding completion.

sequenceDiagram
    participant ST as Student
    participant AC as TokenAllotmentRequestController
    participant AS as TokenAllotmentRequestService
    participant BL as Batch Lead
    participant TXN as DB Transaction

    Note over AC: every handler returns 503 if ENABLE_TOKEN_REQUEST_FLOW is off

    ST->>AC: POST student token-requests with mock resume and reason
    AC->>AS: createStudentRequest
    AS->>AS: validate amounts positive and reason present
    AS->>TXN: reject if a pending request already exists
    AS->>TXN: resolve batchId from student Application
    AS->>TXN: insert token_allotment_requests status pending
    AS-->>AC: created request
    AC-->>ST: 201 request submitted

    BL->>AC: GET batchlead token-requests filtered by batch
    AC->>AS: listRequestsForBatchLead via BatchLeadAssignment and Batch
    AS-->>BL: scoped request list

    BL->>AC: PATCH batchlead token-requests id action approve
    AC->>AS: reviewRequest
    AS->>TXN: start transaction and lock the request row
    alt request not pending
        TXN-->>AS: error already approved or rejected
        AS-->>BL: 400 already reviewed
    else BL not authorized for batch
        TXN-->>AS: error not authorized
        AS-->>BL: 400 not authorized
    else onboarding incomplete
        Note over TXN: requires diagnosticCompleted and meetingCompleted
        TXN-->>AS: error complete onboarding first
        AS-->>BL: 400 onboarding pending
    else approve
        TXN->>TXN: lock or create promotional_tokens row
        TXN->>TXN: add mockTokens to meetToken and resumeTokens to resumeToken
        TXN->>TXN: extend expiredAt and sync tokenValue
        TXN->>TXN: insert token_usage bonus magnitude total granted
        TXN->>TXN: set request approved with reviewer and timestamp
        TXN-->>AS: commit
        AS-->>BL: 200 request approved
    end

Reject path: reviewNotes is required, the row is locked and re-checked as pending, then set to rejected with reviewer + notes — no token movement. Withdraw path: a student can only withdraw their own pending request (withdrawStudentRequest).

7. Governance — resume-review spend

Spending a resume token mirrors meeting booking: promo resumeToken first, then wallet resumeToken, all in one locked transaction, writing a RESUME_REVIEW ledger row.

sequenceDiagram
    participant ST as Student
    participant RC as ResumeReviewController
    participant RS as ResumeReviewService
    participant TXN as DB Transaction

    Note over RC: returns 503 if ENABLE_RESUME_REVIEW_SPEND is off

    ST->>RC: POST resume-reviews with resumeUrl
    RC->>RS: createRequest
    RS->>TXN: start transaction and lock promotional_tokens
    alt valid promo resumeToken at least 1
        TXN->>TXN: decrement promo resumeToken and sync tokenValue
        TXN->>TXN: source is promotional
    else fall back to wallet
        TXN->>TXN: lock tokens row and read resumeToken
        alt wallet resumeToken below 1
            TXN-->>RS: rollback Insufficient resume tokens
            RS-->>ST: 400 request more from Batch Lead
        else wallet resumeToken at least 1
            TXN->>TXN: decrement wallet resumeToken
            TXN->>TXN: source is wallet
        end
    end
    TXN->>TXN: insert resume_review_requests status pending
    TXN->>TXN: insert token_usage resume_review magnitude 1
    TXN-->>RS: commit
    RS-->>ST: 201 review created

8. Ledger reconciliation (derived snapshot)

StudentTokenLedgerService.getSnapshot reconstructs an allotted-vs-used picture without storing it: it sums token_usage per type and adds current balances. updateAllottedTokens lets an operator set a target allotted number; the service back-solves the wallet balance as allotted - used - validPromo, clamped at zero.

sequenceDiagram
    participant OP as Admin or dashboard
    participant LS as StudentTokenLedgerService
    participant DB as PostgreSQL

    OP->>LS: getSnapshot studentId
    LS->>DB: find tokens row
    LS->>DB: find promotional_tokens row
    LS->>DB: sum token_usage meeting_booking as mockUsed
    LS->>DB: sum token_usage resume_review as resumeUsed
    Note over LS: allotted equals used plus walletBalance plus validPromo
    LS-->>OP: snapshot allotted used per bucket and totals

    OP->>LS: updateAllottedTokens with mockAllotted target
    LS->>DB: set wallet token to max of zero and target minus used minus promo
    LS-->>OP: refreshed snapshot

Background jobs & async

  • Cleanup queue (BullMQ). SlotCleanupService runs on a 24h schedule (see src/workers/cleanup.worker.ts and the cleanup job scheduled at startup). It finds expired pending meeting requests, sets them CANCELLED, recreates the available slot, and refunds 1 token via TokenService.addTokensToUser (usageType: REFUND). This is the only async token mutation.
  • Email queue. Booking success queues a mentor approval email; cancellations queue cancellation-requested emails. These are notification side effects, not token mutations.
  • WhatsApp invoice notify. After a verified purchase, sendTokenPurchaseInvoiceWhatsApp fires best-effort (tries the mas_invoice template, falls back to plain text). It is awaited inside a try/catch and never blocks the token credit.
  • No webhooks for tokens. Payment confirmation is client-driven via POST /api/tokens/verify-payment (HMAC signature verification), not a Razorpay server webhook in this domain.
  • No Socket.IO events are specific to tokens; balances are fetched on demand.

External integrations

Integration Used for Env vars Failure / fallback
Razorpay Token purchase orders, invoices, payment verification RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET If keys are unset, razorpay is null; createTokenPurchase throws "Razorpay is not configured" and verifyAndCompletePayment returns a failure. Invoices API failure falls back to orders.create.
WhatsApp (WhatsAppService) Emailing/sending the token-purchase invoice provider creds resolved by WhatsAppService Skipped if no phone or no configured provider; template failure falls back to plain text; entire block is best-effort.
MAS website URL Invoice portal deep link in WhatsApp body MAS_WEBSITE_URL Defaults to https://www.myanalyticsschool.com.
TOKEN_VALUE Mentor-earning rupee valuation TOKEN_VALUE Defaults to 350.

Feature flags (src/utils/featureFlags.ts)

Flags are default-ON: a flag is OFF only if its env var is exactly "false" or "0". They are kill-switches, not opt-ins — set the var and pm2 reload to disable without a redeploy.

Flag Env var Gates
tokenRequestFlow ENABLE_TOKEN_REQUEST_FLOW All student/BL token-request endpoints (503 when off).
resumeReviewSpend ENABLE_RESUME_REVIEW_SPEND Resume-review spend endpoints (503 when off).
starterBundle ENABLE_STARTER_BUNDLE Reserved — auto-grant starter bundle. Not yet consumed.
grantAudit ENABLE_GRANT_AUDIT Reserved — audit rows on legacy direct grant. Not yet consumed.
applicationMeetings ENABLE_APPLICATION_MEETINGS Free applicant meetings (adjacent flow).

Rollout context: Token Governance Phase 1 (see docs/TOKEN_GOVERNANCE_PHASE1_DEPLOY.md in the backend repo, dated 2026-04-18) shipped the two new tables (token_allotment_requests, resume_review_requests) and their routes additively. TypeORM synchronize: true creates the tables + indexes on first boot; no ALTER on existing tables. The deploy is safe with flags either ON or OFF — OFF means endpoints 503 with zero DB work.


Status lifecycles

TokenPurchase (PaymentStatus)

stateDiagram-v2
    [*] --> pending : createTokenPurchase
    pending --> completed : verifyAndCompletePayment signature valid
    pending --> cancelled : DELETE tokens purchase id
    pending --> failed : payment failure (inferred)
    completed --> [*]
    cancelled --> [*]
    failed --> [*]
    note right of completed : wallet credited here, terminal

failed is defined in the enum but not set by the current code paths shown (cancellation sets cancelled); marked (inferred) as the intended terminal for declined payments.

TokenAllotmentRequest (TokenAllotmentRequestStatus)

stateDiagram-v2
    [*] --> pending : student createStudentRequest
    pending --> approved : BL approve, promo credited, bonus ledger row
    pending --> rejected : BL reject with required notes
    pending --> withdrawn : student withdraws own request
    approved --> [*]
    rejected --> [*]
    withdrawn --> [*]
    note right of pending : only one pending request per student allowed

ResumeReviewRequest (ResumeReviewRequestStatus)

stateDiagram-v2
    [*] --> pending : createRequest token spent
    pending --> under_review : reviewer picks it up
    under_review --> completed : review delivered
    pending --> rejected : declined
    under_review --> rejected : declined
    completed --> [*]
    rejected --> [*]

Slot spend/refund interplay (token side)

stateDiagram-v2
    [*] --> booked : requestMeeting debits 1 token, slot UNDER_REVIEW
    booked --> confirmed : mentor confirms, no token change
    confirmed --> cancellation_requested : student requests cancel
    cancellation_requested --> refunded : mentor approves, plus 1 REFUND
    booked --> refunded : pending request expires via cleanup worker
    refunded --> [*]

Edge cases, limits & gotchas

  • No true hold state. Booking debits immediately; there is no escrow/hold row. The refund path is the compensating action. If a refund call fails after the slot transaction commits (cleanup worker case), the source itself notes it is "not fully transaction-safe."
  • Promo-before-wallet ordering. Every spend path (meeting booking, resume review) consumes valid promotional tokens before wallet tokens. Expired promos silently contribute zero — they are never cleaned up, just ignored on read.
  • POST /api/tokens/deduct writes no ledger row. The controller calls deductTokensFromUser with no options, so it mutates the wallet but skips recordTokenUsage. The real booking flow does not use this endpoint; treat it as a low-level/legacy utility and prefer MentorService for auditable spends.
  • Purchase credit writes no ledger row. verifyAndCompletePayment calls addTokensToUser without options, so successful purchases are auditable only via token_purchases, not token_usage. Only refund/bonus credits land in the ledger.
  • int vs float mismatch. tokens.token / resumeToken are float; token_usage balance columns are int. Fractional balances would be truncated in the ledger snapshot.
  • Idempotency on verify. A second verify-payment for a completed purchase returns "Payment already completed" (no double credit). The verifier tries multiple HMAC formulas (order-flow and invoice-flow) to tolerate Checkout callbacks that omit order_id, and can fall back to the most recent pending purchase for the user when neither order nor invoice id is supplied.
  • Governance auth nuance. reviewRequest re-validates BL authorization against BatchLeadAssignment (active) or the denormalised Batch.batchLeadId, inside the locked transaction. Approval also requires the student to have diagnosticCompleted and meetingCompleted on their Application — otherwise it errors with "Complete onboarding first."
  • One pending request per student. createStudentRequest rejects a new request while any pending one exists (anti-spam).
  • Flags default ON. Forgetting that the kill-switch is false/0 (not "unset = off") is a common trap: an empty or true value leaves governance enabled.
  • Multi-platform. Token logic is platform-agnostic; the x-platform header does not branch token behavior in these services.
  • Reserved flags. ENABLE_STARTER_BUNDLE and ENABLE_GRANT_AUDIT exist but no code path consumes them yet (Phase 2+ placeholders).