Payments, Finance & GST Invoicing¶
This domain covers every flow where money moves through the MAS / Mr. Mentor backend: a student paying for tokens via Razorpay, the Finance Portal that mirrors all Razorpay payments into a local store and enriches each into a GST-compliant tax invoice (HSN/SAC, state-code routing, CGST/SGST/IGST/UTGST), the sales discount request → admin approval workflow that sets the price a student actually pays, an append-only GST audit trail, password-protected finance exports, and the internal pipeline cost ledger that tracks the AI/LLM/telephony spend per recruitment job. It is the system of record for revenue recognition, tax reporting (GSTR-1 style summaries) and reconciliation against Razorpay.
Status: documented from source on this branch.
Overview¶
The domain has several distinct surfaces, each with its own actors:
| Surface | Actor / role | What they do |
|---|---|---|
| Token purchase | USER (student) |
Buy mentorship tokens with Razorpay, credited on signature verification |
| Finance Portal | FINANCE, ADMIN |
Reconcile Razorpay payments, generate & issue GST invoices, run reports, export protected sheets, manage finance-team users |
| Discount requests | SALES (create), ADMIN (approve) |
Negotiate a per-student enrollment price below the batch fee |
| GST masters | FINANCE, ADMIN |
Maintain HSN/SAC rate master and state-code master |
| Pipeline cost ledger | recruitment HR users + ADMIN |
View AI/telephony spend per job / candidate |
Two payment "engines" coexist:
- Live token purchases —
TokenServicetalks to the Razorpay SDK directly (orders / invoices API), verifies the HMAC signature on the client callback, and credits tokens. This is the only place the system initiates a charge. - Reconciliation + invoicing —
GlobalPaymentSyncServicepulls all Razorpay payments (regardless of how they originated) intosuperadmin.global_payments, then turns each into a legal tax invoice. This is read-mostly: Razorpay source data is authoritative, only the invoice-side fields are editable.
Mount-path note: the finance routes are mounted at
/api/finance(src/routes/index.ts:383). Many JSDoc comments insrc/controllers/finance.controller.tsstill say/api/superadmin/...— those are stale; the real prefix is/api/finance.Scope note: the task brief referenced
FinanceAuditService,financeAuditmiddleware and aFinanceAuditLogentity. Those do not exist in the codebase. The only immutable audit trail in this domain isGstAuditLog(superadmin.gst_audit_log), written byGlobalPaymentSyncService.writeAudit. Where the brief says "finance audit logging", read "GST audit logging".
Key concepts & entities¶
Glossary
- Token purchase — a student buys mentorship tokens; one
TokenPurchaserow, PENDING → COMPLETED after Razorpay signature verification. - Global payment — one canonical mirrored row per Razorpay payment in
superadmin.global_payments, enriched into a GST invoice. - Invoice series — which product an invoice belongs to (
MAS101,MAS102,PAP,AERO,DRO). Drives the number prefix; numbering resets per financial year, per series. - HSN/SAC — tax classification code for the line item (courses use SAC
999293, MIT technical services use999223). - State code — 2-digit GST state code (supplier is Delhi =
07). Decides intra-state (CGST+SGST), inter-state (IGST) or union-territory (CGST+UTGST). - GST-inclusive — amounts paid are treated as tax-inclusive; the engine
back-computes the taxable value as
total / (1 + rate/100). - B2C unregistered — default buyer when there is no GSTIN; place of supply defaults to the supplier state → intra-state CGST+SGST.
- Discount request — sales-proposed, admin-approved per-student price below
Batch.enrollmentFee; on approval setsApplication.finalAmount. - Pipeline cost — internal USD spend (LLM tokens, voice-call minutes) per recruitment job/candidate. Not customer-facing money.
Entities
| Entity | File | Table | Notes |
|---|---|---|---|
TokenPurchase |
src/entities/TokenPurchase.ts |
token_purchases |
PaymentStatus enum; Razorpay order/payment/signature/invoice ids |
GlobalPayment |
src/entities/GlobalPayment.ts |
superadmin.global_payments |
Razorpay mirror + full GST invoice layout (~70 columns) |
GstAuditLog |
src/entities/GstAuditLog.ts |
superadmin.gst_audit_log |
Append-only; one row per changed field |
GstStateCode |
src/entities/GstStateCode.ts |
superadmin.gst_state_codes |
code PK, is_ut flag |
HsnSacMaster |
src/entities/HsnSacMaster.ts |
superadmin.hsn_sac_master |
code PK, gst_rate, is_exempt, active |
DiscountRequest |
src/entities/DiscountRequest.ts |
discount_requests |
DiscountRequestStatus enum; price snapshot |
PipelineCostLedger |
src/entities/PipelineCostLedger.entity.ts |
pipeline_cost_ledger |
CostStage + CostProvider enums; USD + token counts |
Supporting config & helpers:
src/config/invoiceConfig.ts— supplier identity, series config, GST slabs,computeGst,formatInvoiceNumber,stateCodeFromGstin,amountToWords, bank/terms blocks, S3 + email recipient config.src/utils/invoiceBillingAddress.ts— derives Razorpay billing-address lines for a user.
Architecture¶
flowchart TD
subgraph FE["Frontends"]
Student["Student portal (token buy)"]
Finance["Finance Portal UI"]
SalesUI["Sales CRM UI"]
HRUI["Recruitment HR UI"]
end
subgraph Routes["Express routes"]
TokenR["/api/tokens/* (token.routes)"]
FinR["/api/finance/* (finance.routes)"]
SalesR["/api/sales/discount-requests/* (sales.routes)"]
CostR["/api/hr/costs + /api/admin/costs (pipelineCost.routes)"]
end
subgraph Ctrls["Controllers"]
TokenC["TokenController"]
FinC["FinanceController"]
SalesC["SalesController"]
CostC["PipelineCostController"]
end
subgraph Svcs["Services"]
TokenS["TokenService (Razorpay SDK)"]
GPS["GlobalPaymentSyncService"]
RAS["RazorpayAdminService (read-only)"]
GMS["GstMasterService"]
GPDF["GstInvoicePdfService (pdf-lib)"]
FRS["FinanceReportsService"]
FES["FinanceExportService (xlsx)"]
DS["DiscountService"]
PCS["PipelineCostService"]
end
subgraph DB["PostgreSQL"]
TP["token_purchases"]
GP["global_payments"]
GAL["gst_audit_log"]
SC["gst_state_codes"]
HSN["hsn_sac_master"]
DR["discount_requests"]
PCL["pipeline_cost_ledger"]
end
subgraph Ext["External systems"]
RZP["Razorpay API"]
S3["AWS S3 (secured invoice bucket)"]
SMTP["Gmail SMTP via EmailService"]
Queue["BullMQ emailQueue"]
end
Student --> TokenR --> TokenC --> TokenS
Finance --> FinR --> FinC
SalesUI --> SalesR --> SalesC --> DS
HRUI --> CostR --> CostC --> PCS
TokenS --> RZP
TokenS --> TP
FinC --> GPS
FinC --> RAS
FinC --> FRS
FinC --> FES
GPS --> RAS --> RZP
GPS --> GMS
GPS --> GPDF
GPS --> GP
GPS --> GAL
GMS --> SC
GMS --> HSN
GPS --> S3
GPS --> SMTP
DS --> DR
DS --> Queue
PCS --> PCL
FRS --> GP
Data model¶
erDiagram
USER ||--o{ TOKEN_PURCHASE : "buys"
USER ||--o{ DISCOUNT_REQUEST : "requests"
APPLICATION ||--o{ DISCOUNT_REQUEST : "for"
BATCH ||--o{ DISCOUNT_REQUEST : "priced from"
GLOBAL_PAYMENT ||--o{ GST_AUDIT_LOG : "audited by"
HSN_SAC_MASTER ||--o{ GLOBAL_PAYMENT : "rate source"
GST_STATE_CODE ||--o{ GLOBAL_PAYMENT : "place of supply"
JOB_POST ||--o{ PIPELINE_COST_LEDGER : "incurs"
APPLICATION ||--o{ PIPELINE_COST_LEDGER : "candidate cost"
TOKEN_PURCHASE {
uuid id PK
uuid userId FK
int tokenQuantity
decimal amount
string paymentStatus "pending|completed|failed|cancelled"
string razorpayOrderId
string razorpayPaymentId
string razorpaySignature
string razorpayInvoiceId
timestamp completedAt
}
GLOBAL_PAYMENT {
uuid id PK
string razorpayPaymentId UK
bigint amountPaise
bigint amountRefundedPaise
string status
string method
boolean captured
string invoiceSeries "MAS101|MAS102|PAP|AERO|DRO"
string taxInvoiceNumber UK
int invoiceSequence
string financialYear
string hsnSacCode
numeric taxableValue
numeric gstRate
numeric cgstAmount
numeric sgstAmount
numeric igstAmount
numeric utgstAmount
numeric totalTaxAmount
string recipientStateCode
boolean isInterState
boolean taxExempt
boolean invoiceGenerated
boolean isManual
string invoicePdfS3Key
timestamp invoiceEmailedAt
}
GST_AUDIT_LOG {
uuid id PK
string paymentId FK
string invoiceNumber
string action "create|update|issue"
string field
text oldValue
text newValue
string changedBy
string changedByEmail
timestamp createdAt
}
GST_STATE_CODE {
string code PK
string name
boolean isUt
}
HSN_SAC_MASTER {
string code PK
string description
numeric gstRate
boolean isExempt
boolean active
}
DISCOUNT_REQUEST {
uuid id PK
uuid applicationId FK
uuid batchId FK
uuid requestedBy FK
decimal originalBatchPrice
decimal requestedPrice
decimal discountAmount
decimal approvedPrice
string status "pending|approved|rejected"
uuid reviewedBy FK
timestamp reviewedAt
}
PIPELINE_COST_LEDGER {
uuid id PK
uuid jobPostId FK
uuid applicationId FK
string stage
string provider
string model
decimal costUsd
int inputTokens
int outputTokens
timestamp recordedAt
}
Enums
PaymentStatus(TokenPurchase):pending,completed,failed,cancelled.DiscountRequestStatus:pending,approved,rejected.CostStage(PipelineCostLedger):jd_generation,salary_benchmark,quiz_generation,resume_parsing,resume_embedding,resume_scoring,ai_voice_call,transcript_analysis,other.CostProvider:openai,groq,vapi,elevenlabs,twilio,nvidia,internal.InvoiceSeries(string ininvoiceConfig.ts):MAS101,MAS102,PAP,AERO,DRO.
API surface¶
Token purchases — mounted at /api (src/routes/token.routes.ts)¶
| Method | Path | Auth/role | Purpose |
|---|---|---|---|
| GET | /api/tokens/balance |
auth | Current token balance |
| POST | /api/tokens/purchase |
auth | Create Razorpay order/invoice for a token purchase |
| POST | /api/tokens/verify-payment |
auth | Verify HMAC signature, credit tokens |
| GET | /api/tokens/history |
auth | Purchase history |
| GET | /api/tokens/usage-history |
auth | Token usage history |
| DELETE | /api/tokens/purchase/:purchaseId |
auth | Cancel a pending purchase |
| POST | /api/tokens/deduct |
auth | Deduct tokens (spend) |
Finance Portal — mounted at /api/finance (src/routes/finance.routes.ts)¶
All routes require authMiddleware + financeOnlyMiddleware (FINANCE or ADMIN).
| Method | Path | Purpose |
|---|---|---|
| GET | /api/finance/razorpay/payments |
Live Razorpay payments (read-through) |
| GET | /api/finance/razorpay/invoices |
Live Razorpay invoices |
| GET | /api/finance/settlements |
Live Razorpay settlements |
| GET | /api/finance/overview |
Dashboard KPIs + charts |
| GET | /api/finance/gst |
GSTR-1 style tax summary |
| GET | /api/finance/customers |
Payer directory |
| GET | /api/finance/ledger |
One payer's full ledger (by email/phone) |
| GET | /api/finance/refunds |
Refund register |
| GET | /api/finance/gst-masters |
State codes + HSN/SAC master |
| POST | /api/finance/gst-masters/hsn |
Add/update an HSN/SAC code |
| GET | /api/finance/team |
List finance-role users |
| POST | /api/finance/team |
Create finance-role user (emails creds) |
| PATCH | /api/finance/team/:id |
Activate/deactivate or reset password |
| GET | /api/finance/profile |
Current finance user's details |
| PUT | /api/finance/profile |
Update name/phone/DOB |
| POST | /api/finance/export |
Build password-protected .xlsx and stream |
| GET | /api/finance/global-payments |
List mirrored payments (filters) |
| GET | /api/finance/global-payments/unimported |
Razorpay rows not yet in DB |
| POST | /api/finance/global-payments/sync |
Pull all Razorpay payments into DB |
| POST | /api/finance/global-payments/generate-invoices |
Bulk assign numbers + GST (optional S3 store) |
| POST | /api/finance/global-payments/manual |
Create off-system / manual invoice |
| POST | /api/finance/global-payments/:id/issue |
Render PDF → S3 → email customer + finance + admin |
| GET | /api/finance/global-payments/:id/pdf |
Stream a fresh PDF (download/preview) |
| GET | /api/finance/global-payments/:id/audit |
GST modification history |
| GET | /api/finance/global-payments/:id |
Full record |
| PATCH | /api/finance/global-payments/:id |
Edit invoice / GST fields |
Static sub-paths (
/unimported,/sync,/generate-invoices,/manual) are registered before the dynamic/:idroute so they are not swallowed as an id.
Discount requests — mounted at /api/sales (src/routes/sales.routes.ts)¶
All routes require authMiddleware (applied at router level).
| Method | Path | Auth/role | Purpose |
|---|---|---|---|
| POST | /api/sales/discount-requests |
auth (sales) | Create a discount request |
| GET | /api/sales/discount-requests |
auth | List requests (admin = all, sales = own) |
| GET | /api/sales/discount-requests/pending-count |
auth | Count of pending requests |
| GET | /api/sales/discount-requests/application/:id |
auth | Active request for an application |
| PATCH | /api/sales/discount-requests/:id/review |
auth + adminMiddleware |
Approve / reject |
Pipeline cost — mounted at /api (src/routes/pipelineCost.routes.ts)¶
| Method | Path | Auth/role | Purpose |
|---|---|---|---|
| GET | /api/hr/costs/dashboard |
auth | HR cost dashboard (own jobs) |
| GET | /api/hr/costs/jobs |
auth | All-jobs summary for the HR user |
| GET | /api/hr/costs/jobs/:jobPostId/summary |
auth | Per-job cost summary |
| GET | /api/hr/costs/jobs/:jobPostId/candidates |
auth | Per-candidate costs in a job |
| GET | /api/hr/costs/candidates/:applicationId/summary |
auth | One candidate's cost summary |
| GET | /api/hr/costs/ledger |
auth | Raw ledger rows |
| GET | /api/admin/costs/dashboard |
auth + admin | System-wide cost dashboard |
| GET | /api/admin/costs/by-hr |
auth + admin | Cost breakdown by HR user |
| GET | /api/admin/costs/hr/:userId/jobs |
auth + admin | One HR user's per-job detail |
User journeys¶
1. Buy tokens with Razorpay and get credited¶
A student initiates a token purchase. The backend prefers Razorpay's Invoices
API (so Razorpay emails an official invoice) and falls back to orders.create
if that fails. On the client callback, TokenService tries several HMAC
signature formulas to cover both the order flow and the invoice flow.
sequenceDiagram
participant FE as Student frontend
participant API as TokenController
participant TS as TokenService
participant DB as token_purchases
participant RZP as Razorpay
FE->>API: POST /api/tokens/purchase with quantity
API->>TS: createTokenPurchase dto
TS->>DB: save TokenPurchase PENDING
TS->>RZP: invoices.create preferred
alt invoice creation fails
TS->>RZP: orders.create fallback
end
TS->>DB: store razorpayOrderId and invoice id and url
TS-->>API: razorpayOrder plus key id
API-->>FE: order details for Checkout
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 purchase by order or invoice or latest pending
TS->>TS: HMAC sha256 over candidate bodies vs signature
alt no candidate matches
TS-->>API: error Invalid signature
API-->>FE: 400 verification failed
else signature matches
TS->>DB: set status COMPLETED and store paymentId and completedAt
TS->>DB: credit tokens to user account
TS->>RZP: send invoice WhatsApp best effort
TS-->>API: success with tokenPurchase
API-->>FE: 200 tokens credited
end
Key guards (src/services/TokenService.ts):
- If Razorpay keys are unset,
createTokenPurchasethrows "Razorpay is not configured";verifyAndCompletePaymentreturns{ success: false }. - A purchase already
COMPLETEDreturns "Payment already completed" (idempotent re-verification guard). - Signature candidates include
order|payment(from DB and from callback),invoice|receipt|status|payment,payment|invoiceandinvoice|payment.
2. Reconcile Razorpay → generate a GST tax invoice¶
Finance staff pull all Razorpay payments into the local mirror, then enrich each into a numbered GST invoice. The amount is treated as GST-inclusive; the taxable value and tax heads are back-computed and routed by state code.
sequenceDiagram
participant FE as Finance Portal
participant API as FinanceController
participant GPS as GlobalPaymentSyncService
participant RAS as RazorpayAdminService
participant RZP as Razorpay
participant GMS as GstMasterService
participant DB as global_payments
FE->>API: POST /api/finance/global-payments/sync
API->>GPS: syncAllPayments from and to
loop pages of 100 up to 20k
GPS->>RAS: listPayments count skip
RAS->>RZP: payments.all
RZP-->>RAS: page of payments
GPS->>DB: upsert by razorpayPaymentId idempotent
end
GPS-->>API: fetched created updated counts
API-->>FE: sync result
FE->>API: POST /api/finance/global-payments/generate-invoices storePdf true
API->>GPS: generateInvoices ids optional
loop each captured pending row oldest first
GPS->>GPS: assignInvoiceFields
GPS->>GMS: resolveRate for hsn sac code
GMS->>DB: read hsn_sac_master cached
GPS->>GMS: isUt for recipient state
GPS->>GPS: computeGst total rate stateCode
GPS->>DB: save row with number series GST split invoiceGenerated true
end
GPS-->>API: generated stored counts
API-->>FE: backfill result
Number assignment (assignInvoiceFields + nextSequence): the financial year is
Apr–Mar; the next sequence is MAX(invoice_sequence)+1 scoped to
(financial_year, invoice_series), so numbering resets each FY and is
independent per series. Re-running keeps the existing number (idempotent).
3. Issue one invoice end-to-end (PDF → S3 → email)¶
sequenceDiagram
participant FE as Finance Portal
participant API as FinanceController
participant GPS as GlobalPaymentSyncService
participant PDF as GstInvoicePdfService
participant S3 as AWS S3 secured bucket
participant MAIL as EmailService
FE->>API: POST /api/finance/global-payments/:id/issue
API->>GPS: issueInvoice id email true
GPS->>GPS: assignInvoiceFields if not numbered
GPS->>PDF: generateGstInvoicePdf invoice data
PDF-->>GPS: pdf buffer
alt invoice bucket configured
GPS->>S3: uploadPrivateObject tax invoice key
GPS->>GPS: set invoicePdfS3Key
else bucket not set
GPS->>GPS: warn skip S3 store
end
alt email enabled and recipient resolvable
GPS->>MAIL: send to customer cc finance and admin with pdf attached
GPS->>GPS: set invoiceEmailedAt
else no recipient
GPS-->>API: emailError no recipients
end
GPS->>GPS: writeAudit is NOT called on issue path
GPS-->>API: row pdfStored emailed
API-->>FE: 200 issue result
Notes:
getInvoicePdfBuffer(theGET /:id/pdfendpoint) renders a fresh PDF on demand and does not require the invoice to be issued; it back-fills derived fields first if the row has never been numbered.- Email recipients:
to= recipient/customer email;cc=GLOBAL_INVOICE_FINANCE_EMAIL+GLOBAL_INVOICE_ADMIN_EMAIL. If there is no customer email, the first finance/admin address becomes the primaryto.
4. Manual / off-system invoice with GST audit¶
For payments collected outside Razorpay, finance creates a manual entry. A
synthetic MANUAL-<uuid> payment id is stored, GST is computed immediately, and
a create audit row is written.
sequenceDiagram
participant FE as Finance Portal
participant API as FinanceController
participant GPS as GlobalPaymentSyncService
participant GMS as GstMasterService
participant DB as global_payments
participant AUD as gst_audit_log
FE->>API: POST /api/finance/global-payments/manual with amount and recipient
API->>GPS: createManual input actor
GPS->>GPS: validate amount positive
GPS->>GPS: validateGstinState GSTIN first two digits vs state
alt GSTIN state mismatch
GPS-->>API: 400 GSTIN does not match state
else valid
GPS->>GPS: assignInvoiceFields assigns number and GST
GPS->>GMS: resolveRate and isUt
GPS->>DB: save manual row invoiceGenerated true
GPS->>AUD: writeAudit action create one row per field
GPS-->>API: saved row
API-->>FE: 201 created
end
5. Edit an issued invoice and recompute tax (audited)¶
sequenceDiagram
participant FE as Finance Portal
participant API as FinanceController
participant GPS as GlobalPaymentSyncService
participant DB as global_payments
participant AUD as gst_audit_log
FE->>API: PATCH /api/finance/global-payments/:id with edited fields
API->>GPS: update id patch actor
GPS->>DB: load row
GPS->>GPS: snapshot AUDIT_FIELDS before change
GPS->>GPS: apply only whitelisted editable fields
GPS->>GPS: validateGstinState
alt tax inputs changed and amounts not hand edited
GPS->>GPS: recompute computeGst and overwrite tax heads
else tax amounts hand edited
GPS->>GPS: respect manual override
end
GPS->>DB: save row
GPS->>AUD: writeAudit action update for each changed field
GPS-->>API: saved row
API-->>FE: 200 updated
Razorpay source columns (amountPaise, status, razorpayPaymentId,
rawPayload) are not in the editable whitelist — they stay authoritative.
6. Sales discount request → admin approval¶
sequenceDiagram
participant SalesFE as Sales CRM
participant API as SalesController
participant DS as DiscountService
participant DB as discount_requests
participant AppDB as applications
participant Q as BullMQ emailQueue
participant AdminFE as Admin
SalesFE->>API: POST /api/sales/discount-requests appId price reason
API->>DS: createRequest
DS->>DB: check no active pending or approved request
alt requested price not below enrollment fee
DS-->>API: error price must be less than current price
API-->>SalesFE: 400
else valid
DS->>DB: save PENDING with price snapshot
DS-->>API: request
API-->>SalesFE: created
end
Note over AdminFE,API: Admin reviews
AdminFE->>API: PATCH /api/sales/discount-requests/:id/review approve
API->>DS: reviewRequest admin approve approvedPrice
alt request not pending
DS-->>API: error only pending can be reviewed
else approve
DS->>DB: set APPROVED approvedPrice reviewedBy reviewedAt
DS->>AppDB: set Application.finalAmount and discountApplied
DS->>Q: queue discount-approved-student email
DS->>Q: queue discount-approved-sales email
DS-->>API: updated request
API-->>AdminFE: 200 approved
end
The admin may approve at a price different from what sales requested
(approvedPrice), but it must still be > 0 and < originalBatchPrice.
Rejection just flips the status and records the reviewer/note; sales may retry
with a new request.
7. Finance reports + password-protected export¶
sequenceDiagram
participant FE as Finance Portal
participant API as FinanceController
participant FRS as FinanceReportsService
participant FES as FinanceExportService
participant DB as global_payments
FE->>API: GET /api/finance/overview from to
API->>FRS: overview
FRS->>DB: aggregate KPIs trend method series status splits
FRS-->>API: dashboard data
API-->>FE: 200 charts
FE->>API: POST /api/finance/export filename sheets
API->>API: load user then derivePassword from dob and phone
alt profile incomplete no dob or phone
API-->>FE: 400 PROFILE_INCOMPLETE
else password derived
API->>FES: buildEncrypted sheets password
FES-->>API: encrypted xlsx buffer
API-->>FE: 200 attachment download
end
The export password is derived server-side as DD-MM-YYYY-last4 from the
finance user's date of birth and phone (FinanceExportService.derivePassword);
the client never sends it. Without a DOB + phone on the user row, export returns
PROFILE_INCOMPLETE.
8. Pipeline cost recording (internal spend)¶
Recruitment services record AI/telephony spend as fire-and-forget ledger rows; HR and admin then read aggregated dashboards.
sequenceDiagram
participant Pipe as Recruitment pipeline stage
participant PCS as PipelineCostService
participant DB as pipeline_cost_ledger
participant HRFE as HR dashboard
participant API as PipelineCostController
Pipe->>PCS: recordLlmCost or recordCallCost
PCS->>DB: insert ledger row best effort
Note over PCS: failures are swallowed and logged non-blocking
HRFE->>API: GET /api/hr/costs/dashboard
API->>PCS: getHrDashboard userId
PCS->>DB: aggregate by stage provider job
PCS-->>API: summary
API-->>HRFE: 200 cost breakdown
Background jobs & async¶
- Email queue (BullMQ
emailQueue) — discount approval emails to the student and the sales person are enqueued viaQueueService.addEmailJobwith typesdiscount-approved-studentanddiscount-approved-sales(src/services/DiscountService.ts). Failures are caught and never block the approval. - WhatsApp invoice (async, non-blocking) — after a token purchase completes,
TokenService.sendTokenPurchaseInvoiceWhatsAppattempts a WhatsApp message (mas_invoicetemplate), gated on a configured messaging provider. - Invoice email (synchronous) —
issueInvoicesends the GST-invoice email directly throughEmailService(not the queue), attaching the rendered PDF. - GST master seeding —
GstMasterService.ensureSeededlazily seeds the 39-row state-code master and a 2-row HSN/SAC master on first use, then caches lookups in-process (utCache,hsnCache), invalidated onupsertHsn. - No cron — there is no scheduled reconciliation;
syncandgenerate-invoicesare operator-triggered from the Finance Portal.
Webhooks: token payment verification is a client callback
(POST /api/tokens/verify-payment), not a Razorpay server webhook. There is no
Razorpay webhook endpoint in this domain; signature verification happens on the
client-initiated verify call.
External integrations¶
| Integration | Used by | Env vars | Failure / fallback |
|---|---|---|---|
| Razorpay SDK (charge) | TokenService |
RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET |
If unset, purchase/verify return "not configured"; invoice flow falls back to orders.create |
| Razorpay SDK (read) | RazorpayAdminService |
same keys | isConfigured() false → finance reads return 503 |
| AWS S3 (secured invoice bucket) | GlobalPaymentSyncService |
GLOBAL_INVOICE_S3_BUCKET, GLOBAL_INVOICE_S3_PREFIX |
If bucket unset, PDF store is skipped with a warning; issue still succeeds |
| Gmail SMTP | EmailService (invoice + finance creds) |
EMAIL_USER, EMAIL_PASS |
Errors captured into emailError; row still saved |
| Supplier identity / numbering | invoiceConfig.ts |
GLOBAL_INVOICE_SUPPLIER_*, GLOBAL_INVOICE_*_PREFIX, GLOBAL_INVOICE_GST_RATE, GLOBAL_INVOICE_HSN_SAC |
Hardcoded real defaults (GSTIN 07AAECU1161F1ZM, Delhi) |
| Bank details / terms | invoiceConfig.ts |
GLOBAL_INVOICE_BANK_*, GLOBAL_INVOICE_TERMS |
Ship with explicit PENDING — set ... placeholders |
| Invoice email recipients | invoiceConfig.ts |
GLOBAL_INVOICE_FINANCE_EMAIL, GLOBAL_INVOICE_ADMIN_EMAIL |
Admin defaults to admin@myanalyticsschool.com |
WhatsAppService |
provider creds | Gated on hasConfiguredMessagingProvider; silently skipped otherwise |
|
| PDF rendering | GstInvoicePdfService |
none | Uses pdf-lib + bundled MAS logo |
| Excel export | FinanceExportService |
none | Uses xlsx-populate; password from user profile |
Razorpay MCP note: in this environment the Razorpay MCP tool is OAuth-gated and cannot be used to inspect live Razorpay data; all reads here go through the server-side
RazorpayAdminServiceusing the configured API keys.
GST routing logic (computeGst): the supplier is Delhi (07). Buyer state is
recipientStateCode if known, else defaults to the supplier state (B2C → place of
supply = supplier). Same state → CGST + SGST (each rate/2); different state →
IGST (full rate); union territory without legislature (is_ut) → CGST + UTGST.
Exempt or 0% → no tax, taxable value = full amount. Tax heads are split so they
sum back to the total tax with no 1-paisa drift.
Status lifecycles¶
TokenPurchase.paymentStatus¶
stateDiagram-v2
[*] --> PENDING : createTokenPurchase
PENDING --> COMPLETED : verifyAndCompletePayment signature valid
PENDING --> CANCELLED : cancelTokenPurchase
PENDING --> FAILED : payment abandoned or failed
COMPLETED --> COMPLETED : re-verify rejected already completed
CANCELLED --> [*]
FAILED --> [*]
COMPLETED --> [*]
DiscountRequest.status¶
stateDiagram-v2
[*] --> PENDING : sales createRequest
PENDING --> APPROVED : admin approve sets Application finalAmount
PENDING --> REJECTED : admin reject
REJECTED --> PENDING : sales retries new request
APPROVED --> [*]
GlobalPayment invoice lifecycle¶
stateDiagram-v2
[*] --> MIRRORED : syncAllPayments upsert from Razorpay
[*] --> MANUAL : createManual off system entry
MIRRORED --> NUMBERED : generateInvoices assigns number and GST
MANUAL --> NUMBERED : assigned at creation
NUMBERED --> ISSUED : issueInvoice PDF to S3 and emailed
ISSUED --> ISSUED : re-issue keeps same number idempotent
NUMBERED --> NUMBERED : update recomputes GST audited
ISSUED --> NUMBERED : update recomputes GST audited
Edge cases, limits & gotchas¶
- Mount path vs JSDoc. Real prefix is
/api/finance; controller JSDoc says/api/superadmin/.... Trustsrc/routes/index.tsandfinance.routes.ts. - FINANCE role is portal-scoped.
financeOnlyMiddlewareallowsFINANCEandADMINonly, and finance users do not bypass any other role guard. - Money units. Razorpay amounts are stored in paise (
amountPaise, bigint as string); GST/invoice values are rupeesnumeric(14,2). The invoice engine divides paise by 100 and treats the result as GST-inclusive. - Idempotency.
- Token verify rejects an already-
COMPLETEDpurchase. syncAllPaymentsupserts byrazorpayPaymentId(unique).- Invoice numbering is preserved across re-issue/re-generate; a number is only assigned when missing or when the financial year changed.
- Signature multi-formula matching. Because invoice-flow Checkout may omit
order_id,TokenServicetries several HMAC bodies and a last-resort lookup of the user's most recent pending purchase. The DB-stored ids are the primary source of truth, not the callback payload. - GSTIN ↔ state validation (Rule 1).
validateGstinStatethrows 400 if a GSTIN's first two digits do not match the chosenrecipientStateCode(enforced on both manual create and update). - Rate resolution precedence. An explicit non-zero
gstRate(ortaxExempt) wins; otherwise the HSN/SAC master rate; otherwiseDEFAULT_GST_RATE(18). - Hand-edited tax overrides. On PATCH, if the caller edits tax amounts
directly (
cgstAmount,taxableValue, …) the engine does not recompute and respects the override; recompute only fires when tax inputs change. - Audit is GST-only and append-only.
GstAuditLogrecordscreate/updateper changed field with actor id+email. Theissuepath does not write an audit row (onlycreateandupdatedo). There is no general finance audit log entity. - Bank details / terms are PENDING.
invoiceConfig.tsshipsPENDING — set ...placeholders for bank name/account/IFSC and default terms; these must be set via env before go-live (see theadd-env-varskill). - No
superadminschema auto-create caveat.GlobalPayment,GstAuditLog,GstStateCode,HsnSacMasterlive in the Postgressuperadminschema (notpublic); TypeORM auto-sync manages them but the schema must exist. - Sync safety caps.
syncAllPaymentspages up to 200×100 = 20k payments;previewUnimportedscans up to 50 pages. Larger histories need a date window. - Export gating. Protected
.xlsxexport requires the finance user to have both DOB and phone set; otherwisePROFILE_INCOMPLETE(400). - Pipeline cost is best-effort.
PipelineCostService.recordswallows DB errors and returnsnullso a costing failure never blocks the recruitment pipeline. Costs are in USD, unrelated to customer GST/INR money. - Multi-platform (
x-platform). This domain does not branch on the platform header; supplier identity and invoice series are global, driven by env/config.