Finance & Payments API
Invoice management, payment processing, and receipt generation
Finance & Payments API
Comprehensive developer reference for the EduCRM Finance module. Covers all REST endpoints,
service internals, the double-entry accounting model, BullMQ background queues, and the
test suite. All monetary values are stored as Decimal (rupees, not paise) unless explicitly
noted. Paise conversion happens only at the Razorpay boundary.
All endpoints require authentication via NextAuth session cookie unless otherwise noted.
Responses follow the envelope format { data, error }.
1. Invoices API
1.1 List Invoices
GET /api/finance/invoicesQuery Parameters:
| Parameter | Type | Description |
|---|---|---|
studentId | string | Filter by student ID |
status | string | PENDING, PARTIAL, PAID, OVERDUE, PUBLISHED, PARTIALLY_PAID, CANCELLED, VOID, or the virtual outstanding (includes PENDING + PARTIAL + OVERDUE) |
overdueMin | number | Minimum days overdue (client-side filter) |
overdueMax | number | Maximum days overdue (client-side filter) |
Response:
{
"data": [
{
"id": "clx1abc123",
"invoiceNo": "INV-2026-000042",
"studentName": "Aarav Sharma",
"studentCode": "STU-2026-0015",
"className": "Class 10-A",
"feeHead": "Tuition Fee",
"amount": 25000,
"paidAmount": 12500,
"totalAmount": 25000,
"paid": 12500,
"dueDate": "2026-06-15",
"status": "PARTIALLY_PAID",
"daysOverdue": 0
}
]
}1.2 Generate Invoices for Student
POST /api/finance/invoices/generate
Content-Type: application/jsonPermission: create on fee_collection
Request Body:
{
"studentId": "clx_student_id",
"academicYearId": "clx_year_id",
"instalmentId": "clx_instalment_id"
}instalmentIdis optional. When omitted, generates for every pending instalment on the assigned fee plan.- Idempotent: re-calling for the same (student, year, instalment) skips already-created invoices.
Response (success):
{
"data": [
{
"id": "clx_inv_1",
"invoiceNo": "INV-2026-000043",
"studentId": "clx_student_id",
"totalAmount": "25000.00",
"netAmount": "24500.00",
"concessionAmount": "500.00",
"cgst": "0.00",
"sgst": "0.00",
"totalTax": "0.00",
"paidAmount": "0.00",
"status": "PUBLISHED",
"dueDate": "2026-07-01T00:00:00.000Z"
}
],
"error": null
}Error Response:
{
"data": null,
"error": {
"code": "GENERATE_FAILED",
"message": "No fee plan assigned to student clx_student_id for year clx_year_id"
}
}1.3 Bulk Generate Invoices
POST /api/finance/invoices/generate-bulk
Content-Type: application/jsonPermission: create on fee_collection
Enqueues a BullMQ job for the entire class. Returns immediately with the job ID for polling.
Request Body:
{
"classId": "clx_class_id",
"academicYearId": "clx_year_id",
"sectionId": "clx_section_id"
}sectionIdis optional. When omitted, generates for all sections in the class.
Response:
{
"data": {
"queued": 45,
"jobId": "bullmq-job-abc123"
},
"error": null
}1.4 Poll Bulk Generation Status
GET /api/finance/invoices/bulk-status/{jobId}Polls the BullMQ job state. Use this to show a progress indicator in the UI.
Response:
{
"data": {
"state": "active",
"progress": { "processed": 30, "total": 45 },
"returnValue": null,
"failedReason": null
},
"error": null
}Possible state values: waiting, active, completed, failed, delayed.
1.5 Cancel Invoice
PUT /api/finance/invoices/{id}/cancel
Content-Type: application/jsonPermission: delete on fee_collection
Cancels an invoice and reverses its journal entry. Blocks if the invoice is PAID (use a refund instead).
Request Body:
{
"reason": "Duplicate invoice generated in error"
}Response:
{
"data": {
"id": "clx_inv_1",
"status": "CANCELLED",
"notes": "Cancelled: Duplicate invoice generated in error"
},
"error": null
}Error cases:
{
"data": null,
"error": {
"code": "CANCEL_FAILED",
"message": "Cannot cancel a PAID invoice — process a refund instead"
}
}1.6 Student Invoice Summary
GET /api/finance/invoices/student/{studentId}/summaryReturns aggregated totals for a student's invoices in a given academic year.
2. Payments API
2.1 Record Offline Payment
POST /api/finance/payments/offline
Content-Type: application/jsonPermission: create on payment
Records a payment collected at the counter (cash, cheque, NEFT, etc.). Creates the Payment record, updates the Invoice, posts a journal entry, and enqueues a receipt render job.
Request Body:
{
"invoiceId": "clx_inv_1",
"amount": 12500,
"mode": "CASH",
"collectedById": "clx_user_id",
"notes": "Collected at front desk",
"chequeNumber": null,
"chequeBankName": null,
"chequePresentationDate": null,
"neftUtrNumber": null,
"cashierName": "Mrs. Lakshmi"
}Supported mode values:
CASH, CHEQUE, DD, NEFT, UPI, OTHER, ONLINE_UPI, ONLINE_CARD, ONLINE_NETBANKING, ONLINE_WALLET, DEMAND_DRAFT, RTGS, IMPS
Response:
{
"data": {
"id": "clx_payment_1",
"invoiceId": "clx_inv_1",
"studentId": "clx_student_id",
"amount": "12500.00",
"mode": "CASH",
"status": "SUCCESS",
"paidAt": "2026-04-17T10:30:00.000Z",
"collectedById": "clx_user_id",
"cashierName": "Mrs. Lakshmi"
},
"error": null
}Error cases:
{
"data": null,
"error": {
"code": "PAYMENT_FAILED",
"message": "Amount 30000 exceeds outstanding 25000"
}
}2.2 Record Cheque Bounce
POST /api/finance/payments/{id}/cheque-bounce
Content-Type: application/jsonPermission: update on payment
Marks a cheque payment as bounced. Reverses the original journal entry and rolls back the invoice's paid amount. Only works on CHEQUE mode payments.
Request Body:
{
"reason": "Insufficient funds — returned by HDFC Bank"
}Response:
{
"data": {
"id": "clx_payment_1",
"isChequeBounced": true,
"status": "FAILED",
"notes": "Bounced: Insufficient funds — returned by HDFC Bank"
},
"error": null
}Error cases:
"Only cheque payments can bounce"— mode is not CHEQUE"Already marked bounced"—isChequeBouncedis already true
2.3 Portal: Create Razorpay Order
POST /api/portal/payments
Content-Type: application/jsonAuth: Portal session (parent/student login)
Creates a Razorpay order for the outstanding balance on an invoice. Called from the parent portal before opening the Razorpay checkout SDK.
Request Body:
{
"action": "create-order",
"invoiceId": "clx_inv_1"
}Response:
{
"data": {
"orderId": "order_N1b2c3d4e5f6g7",
"amount": 2500000,
"key": "rzp_live_xxxxxxxxxxxx"
}
}Note: amount here is in paise for the Razorpay SDK. The key is the public Razorpay key ID.
2.4 Portal: Verify Payment
POST /api/portal/payments
Content-Type: application/jsonAuth: Portal session
Called after the Razorpay checkout SDK returns a successful payment. Verifies the HMAC-SHA256 signature, marks the payment as SUCCESS, updates the invoice, creates journal entries, and generates a receipt.
Request Body:
{
"action": "verify",
"orderId": "order_N1b2c3d4e5f6g7",
"paymentId": "pay_A1b2c3d4e5f6",
"signature": "d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3"
}Response:
{
"data": {
"status": "success",
"receiptNo": "RCT-2026-00015"
}
}Uses Redis-based idempotency (payment:verify:{paymentId} key with 24h TTL) to prevent double-processing.
3. Receipts API
3.1 Get Receipt
GET /api/finance/receipts/{paymentId}Returns the receipt PDF URL and receipt number. If no receipt exists yet (e.g., the BullMQ worker has not processed the job), generates it on-the-fly.
Response:
{
"data": {
"pdfUrl": "https://r2.yourdomain.com/finance/receipts/inst_123/2026/RCT-2026-000042.pdf",
"receiptNo": "RCT-2026-000042"
},
"error": null
}3.2 Resend Receipt
POST /api/finance/receipts/{paymentId}/resendForce re-renders the PDF and re-emails the parent. Preserves the original receipt number (does not burn the counter). Runs inline (not enqueued) since it is an admin-initiated action expecting immediate confirmation.
Response:
{
"data": {
"receiptNo": "RCT-2026-000042",
"emailed": true,
"pdfUrl": "https://r2.yourdomain.com/finance/receipts/inst_123/2026/RCT-2026-000042.pdf"
},
"error": null
}4. Fee Structure API
4.1 List Fee Heads
GET /api/finance/fee-headsReturns all fee heads for the institution, ordered alphabetically.
Response:
{
"data": [
{
"id": "clx_fh_1",
"code": "TUITION",
"name": "Tuition Fee",
"description": "Core academic tuition",
"isTaxable": true,
"taxRate": 18,
"isActive": true,
"applicableTo": "ALL",
"requiresAssignment": null,
"autoAddToInvoice": true,
"glAccount": {
"id": "clx_gl_1",
"code": "4001",
"name": "Fee Income (Tuition)"
}
},
{
"id": "clx_fh_2",
"code": "TRANSPORT",
"name": "Transport Fee",
"description": null,
"isTaxable": false,
"taxRate": 0,
"isActive": true,
"applicableTo": "ALL",
"requiresAssignment": "TRANSPORT",
"autoAddToInvoice": true,
"glAccount": {
"id": "clx_gl_2",
"code": "4002",
"name": "Transport Fee Income"
}
}
]
}4.2 Create Fee Head
POST /api/finance/fee-heads
Content-Type: application/jsonRequest Body:
{
"code": "LIBRARY",
"name": "Library Fee",
"description": "Annual library membership",
"isTaxable": false,
"taxRate": 0,
"glAccountId": "clx_gl_5"
}Response (201):
{
"data": {
"id": "clx_fh_new",
"code": "LIBRARY",
"name": "Library Fee",
"isActive": true
}
}Returns 409 if the fee head code already exists.
4.3 Update Fee Head
PATCH /api/finance/fee-heads
Content-Type: application/json{
"id": "clx_fh_1",
"name": "Tuition Fees (Revised)",
"isTaxable": true,
"taxRate": 18,
"isActive": true
}4.4 Delete Fee Head (Soft)
DELETE /api/finance/fee-heads?id=clx_fh_1Soft-deletes by setting isActive: false.
4.5 List Fee Plans
GET /api/finance/fee-plansReturns all fee plans with their items, instalments, assigned classes, and student count.
Response:
{
"data": [
{
"id": "clx_plan_1",
"name": "Standard Day Scholar Plan 2026-27",
"description": "For regular day scholars",
"academicYear": "2026-27",
"academicYearId": "clx_year_1",
"applicableTo": "SPECIFIC_CLASSES",
"isActive": true,
"assignedClasses": [
{ "id": "clx_class_1", "name": "Class 10" }
],
"items": [
{
"id": "clx_item_1",
"feeHead": { "id": "clx_fh_1", "name": "Tuition Fee", "code": "TUITION" },
"amount": "50000.00",
"instalments": [
{
"id": "clx_inst_1",
"instalmentNo": 1,
"dueDate": "2026-06-15",
"amount": "25000.00",
"lateFeePerDay": "50.00",
"gracePeriodDays": 15
},
{
"id": "clx_inst_2",
"instalmentNo": 2,
"dueDate": "2026-12-15",
"amount": "25000.00",
"lateFeePerDay": "50.00",
"gracePeriodDays": 15
}
]
}
],
"studentCount": 120,
"createdAt": "2026-04-01T00:00:00.000Z"
}
]
}4.6 Create Fee Plan
POST /api/finance/fee-plans
Content-Type: application/jsonPermission: manage on fee_structure
Creates a fee plan with items and instalments in a single transaction. Validates that instalment amounts sum to the item amount (within 1 paisa tolerance).
Request Body:
{
"name": "Standard Day Scholar Plan 2026-27",
"description": "For regular day scholars",
"academicYearId": "clx_year_1",
"applicableTo": "SPECIFIC_CLASSES",
"classIds": ["clx_class_1", "clx_class_2"],
"items": [
{
"feeHeadId": "clx_fh_1",
"amount": 50000,
"instalments": [
{
"instalmentNo": 1,
"dueDate": "2026-06-15",
"amount": 25000,
"lateFeePerDay": 50,
"gracePeriodDays": 15
},
{
"instalmentNo": 2,
"dueDate": "2026-12-15",
"amount": 25000,
"lateFeePerDay": 50,
"gracePeriodDays": 15
}
]
}
]
}Response (201):
{
"data": {
"id": "clx_plan_new",
"name": "Standard Day Scholar Plan 2026-27"
}
}Validation error:
{
"error": "Instalment total (₹30,000.00) doesn't match fee item amount (₹50,000.00). Instalments must add up exactly to the item amount."
}4.7 Assign Fee Plan
POST /api/finance/fee-plans/assign
Content-Type: application/jsonAssigns a fee plan to a student (or bulk-assigns to all students in a class) and generates invoices from instalments.
Request Body (single student):
{
"planId": "clx_plan_1",
"studentId": "clx_student_1",
"yearId": "clx_year_1"
}Request Body (bulk by class):
{
"planId": "clx_plan_1",
"classId": "clx_class_1",
"yearId": "clx_year_1"
}Response (201):
{
"data": {
"studentsAssigned": 45,
"invoicesCreated": 90,
"planName": "Standard Day Scholar Plan 2026-27"
}
}4.8 Delete Fee Plan (Soft)
DELETE /api/finance/fee-plans?id=clx_plan_1Soft-deletes by setting isActive: false.
5. Concession Rules
Concessions are managed via the ConcessionService class (src/services/finance/ConcessionService.ts). There is no dedicated REST API for concession rule CRUD yet (planned for Phase 1C.2); rules are currently managed via the generic entity API or direct service calls.
5.1 ConcessionService.createRule
Creates a new concession rule.
const svc = new ConcessionService(prisma, institutionId);
const rule = await svc.createRule({
academicYearId: "clx_year_1",
name: "Sibling Discount (10%)",
code: "SIB-10",
kind: "SIBLING", // SIBLING | MERIT | STAFF_CHILD | CATEGORY | CUSTOM
discountType: "PERCENTAGE", // PERCENTAGE | FLAT | FULL_WAIVER
discountValue: 10,
maxAmount: 5000, // optional cap in rupees
appliesTo: "ALL_HEADS", // ALL_HEADS | SPECIFIC_HEADS
applicableFeeHeadIds: [], // only when appliesTo = SPECIFIC_HEADS
conditions: { siblingCount: 2 }, // kind-specific conditions
requiresApproval: false, // if true, creates PENDING status
});5.2 ConcessionService.applyToStudent
Applies a rule to a specific student. Creates a StudentConcession in PENDING (if rule.requiresApproval) or ACTIVE status. Idempotent via unique constraint on (studentId, ruleId, academicYearId).
const concession = await svc.applyToStudent({
studentId: "clx_student_1",
ruleId: rule.id,
academicYearId: "clx_year_1",
appliedById: "clx_user_id",
feePlanTotal: 50000, // used to compute applied amount
notes: "Applied by admin",
});5.3 ConcessionService.approve / reject
await svc.approve(concession.id, "clx_approver_id");
await svc.reject(concession.id, "Student does not meet criteria");5.4 ConcessionService.deactivateRule
await svc.deactivateRule(rule.id); // sets isActive = false5.5 ConcessionService.listRules
const rules = await svc.listRules("clx_year_1");
// Ordered by: isActive DESC, kind ASC, name ASC5.6 autoDetectAndApplySiblingConcession
Called automatically during InvoiceService.generateForStudent. Detects siblings by finding other active students who share a parent Contact (via ContactStudentLink). If the total sibling count (including the current student) meets the rule's conditions.siblingCount threshold, applies the strongest discount SIBLING rule.
Sibling detection logic:
- Find all parent Contacts linked to the student via
ContactStudentLink - Find all other active students linked to those same parents
- Count unique sibling student IDs
- Match against SIBLING rules (ordered by
discountValuedescending) - Apply the first rule where
siblingCount + 1 >= threshold
Idempotent: skips if a SIBLING-kind StudentConcession already exists for this student/year.
5.7 calculateConcessionAmount (Pure Function)
import { calculateConcessionAmount } from "@/services/finance/ConcessionService";
const amount = calculateConcessionAmount({
discountType: "PERCENTAGE", // "PERCENTAGE" | "FLAT" | "FULL_WAIVER"
discountValue: 10, // 10% for PERCENTAGE, absolute for FLAT
feePlanTotal: 50000, // base amount
maxAmount: 5000, // optional cap
});
// Returns: Decimal(5000) — 10% of 50000 = 5000, capped at 5000Clamping rules:
FULL_WAIVER: discount = feePlanTotal (entire amount waived)PERCENTAGE: discount = feePlanTotal * discountValue / 100FLAT: discount = discountValue- Result is clamped to
[0, feePlanTotal] - If
maxAmountis set, result is further clamped to[0, maxAmount]
6. Academic Years API
6.1 List Academic Years
GET /api/finance/academic-yearsPermission: read on fee_collection
Response:
[
{
"id": "clx_year_1",
"name": "2026-27",
"startDate": "2026-04-01T00:00:00.000Z",
"endDate": "2027-03-31T00:00:00.000Z",
"isActive": true
},
{
"id": "clx_year_2",
"name": "2025-26",
"startDate": "2025-04-01T00:00:00.000Z",
"endDate": "2026-03-31T00:00:00.000Z",
"isActive": false
}
]7. Razorpay Webhook
POST /api/webhooks/razorpay
X-Razorpay-Signature: {hmac_sha256_hex}No auth session required. Signature verification handles authentication.
7.1 Signature Verification
The webhook handler verifies the X-Razorpay-Signature header against multiple possible secrets using crypto.timingSafeEqual:
- Platform-level secret (
RAZORPAY_WEBHOOK_SECRETenv var) -- always checked - Institution-specific secret -- looked up from the institution's gateway config if the institution can be identified from the payment's order
The institution is identified by looking up the pending Payment record via gatewayOrderId and following the relation to the Invoice's student's institutionId.
7.2 Handled Events
payment.captured
For orders with notes.source === "FINANCE_INVOICE", routes through PaymentService.handleRazorpayPaymentCaptured which:
- Checks idempotency (skips if
razorpayPaymentIdalready has a SUCCESS payment) - Updates the pending Payment to SUCCESS
- Updates the Invoice's
paidAmountand status (PAID or PARTIALLY_PAID) - Posts a journal entry via
JournalPostingService.postPaymentReceived - Enqueues receipt generation via BullMQ (fire-and-forget)
- Fires ObjectHook AFTER_CREATE hooks (fire-and-forget)
- Invalidates OptiLearn revenue cache if LMS-sourced (fire-and-forget)
Legacy orders (pre-Phase 1A, no notes.source) fall through to an inline handler.
payment.failed
Marks all pending payments for the order as FAILED.
refund.processed
Marks the payment associated with the razorpayPaymentId as REFUNDED.
7.3 Webhook Payload Example
{
"event": "payment.captured",
"payload": {
"payment": {
"entity": {
"id": "pay_A1b2c3d4e5f6",
"order_id": "order_N1b2c3d4e5f6g7",
"amount": 2500000,
"currency": "INR",
"status": "captured",
"notes": {
"source": "FINANCE_INVOICE",
"invoiceId": "clx_inv_1",
"institutionId": "clx_inst_1",
"studentId": "clx_student_1"
}
}
}
}
}7.4 Idempotency
The handler is safe to call multiple times for the same event. Razorpay retries webhooks for up to 24 hours if it does not receive a 2xx response. The service checks for existing SUCCESS payments with the same razorpayPaymentId before processing, and JournalPostingService checks for existing entries with the same (sourceType, sourceId) before creating journal entries.
8. Payment Gateway Configuration
8.1 Get Current Config
GET /api/admin/payment-gatewayResponse:
{
"data": {
"mode": "OWN_RAZORPAY",
"razorpayKeyId": "rzp_live_xxxxxxxxxxxx",
"hasSecret": true,
"linkedAccountId": null,
"commissionPct": null
}
}Modes: OWN_RAZORPAY, PLATFORM_LINKED, GATEWAY_NONE
8.2 Configure Own Razorpay Account
PATCH /api/admin/payment-gateway
Content-Type: application/jsonPermission: INSTITUTION_ADMIN or SUPER_ADMIN role
Validates credentials with a test API call to Razorpay before saving.
Request Body:
{
"action": "configure-own",
"keyId": "rzp_live_xxxxxxxxxxxx",
"keySecret": "your_key_secret",
"webhookSecret": "your_webhook_secret"
}Response:
{
"data": {
"mode": "OWN_RAZORPAY",
"status": "configured"
}
}8.3 Create Linked Account (Platform Route)
PATCH /api/admin/payment-gateway
Content-Type: application/jsonCreates a Razorpay Route linked account so payments go through the platform with automatic commission splitting.
Request Body:
{
"action": "request-linked",
"name": "John Doe",
"email": "finance@school.edu",
"phone": "+919876543210",
"legalBusinessName": "Springfield School Trust",
"businessType": "educational_institutions",
"pan": "ABCDE1234F",
"gst": "29ABCDE1234F1Z5",
"bankAccount": {
"ifscCode": "SBIN0001234",
"beneficiaryName": "Springfield School Trust",
"accountNumber": "1234567890",
"accountType": "current"
},
"commissionPct": 2.0
}Response:
{
"data": {
"mode": "PLATFORM_LINKED",
"linkedAccountId": "acc_xxxxxxxxxxxxx",
"status": "created"
}
}8.4 Set Commission Percentage
{
"action": "set-commission",
"commissionPct": 2.5
}8.5 Disable Gateway
{
"action": "disable"
}9. Service Architecture (Developer Guide)
All finance services live in src/services/finance/. Each service is instantiated with (prisma, institutionId) to enforce multi-tenant isolation at the constructor level.
9.1 InvoiceService
Location: src/services/finance/InvoiceService.ts
generateForStudent(studentId, academicYearId, instalmentId?)
The core invoice generation flow:
- Fetch plan -- Loads the
StudentFeePlanwith items, fee heads, and instalments - Lock plan -- Sets
isLocked: trueon first generation (prevents structural edits) - Load student context -- Fetches
isRteStudent, activeStudentTransport, activeHostelAllotment - Check existing -- Finds already-created invoices to skip (idempotency)
- Auto-detect sibling concession -- Calls
ConcessionService.autoDetectAndApplySiblingConcession - Load active concessions -- Fetches all ACTIVE/APPROVED
StudentConcessionrows - Filter heads by applicability -- Uses
feeHeadAppliesToStudent()pure function:applicableTo: "DAY"-- excludes RTE and hostel studentsapplicableTo: "RESIDENTIAL"-- only hostel studentsapplicableTo: "RTE"-- only RTE studentsrequiresAssignment: "TRANSPORT"-- only students with active transportrequiresAssignment: "HOSTEL"-- only students with active hostel allotmentautoAddToInvoice: false-- always excluded
- Group by instalment -- One Invoice per instalment, with one InvoiceLine per applicable fee head
- Compute per-line values -- Amount, concession, taxable, GST (CGST/SGST/IGST), line total
- Materialise invoice -- Creates Invoice header + InvoiceLine rows
- Post journal entry --
JournalPostingService.postInvoiceCreated - Fire hooks --
runStandardEntityAfterHooks("Invoice", "AFTER_CREATE", ...)
bulkGenerateForClass(input)
- Finds all active enrolments in the class/section
- Enqueues a BullMQ job on
opticrm-finance:invoices - Returns
{ queued, jobId }for polling
cancelInvoice(invoiceId, reason)
- Validates invoice exists and is not PAID/CANCELLED/VOID
- Reverses the original journal entry via
JournalPostingService.reverseEntry - Updates invoice status to CANCELLED with reason appended to notes
- Fires AFTER_UPDATE hooks
9.2 PaymentService
Location: src/services/finance/PaymentService.ts
createRazorpayOrder(invoiceId) -- Creates a Razorpay order for the outstanding balance. Returns { orderId, amount (paise), currency, key }.
handleRazorpayPaymentCaptured(input) -- Idempotent webhook handler. Updates Payment, Invoice, posts JE, enqueues receipt, fires hooks, invalidates LMS cache.
recordOfflinePayment(input) -- Counter-collected payment. Same post-processing pipeline as online payments (JE + receipt + hooks + LMS sync).
recordChequeBounce(paymentId, reason) -- Marks CHEQUE payment as bounced, reverses JE, rolls back invoice paid amount and status.
9.3 JournalPostingService
Location: src/services/finance/JournalPostingService.ts
The single source of truth for creating JournalEntry + JournalLine rows. No other code path may create journal entries directly.
Methods:
| Method | Debit | Credit | Source Type |
|---|---|---|---|
postInvoiceCreated | Fee Receivables (1003) | Per-head Income (4001-4005) | INVOICE |
postPaymentReceived | Cash (1001) or Bank (1002) | Fee Receivables (1003) | PAYMENT |
postFeeWaiver | Income account | Fee Receivables (1003) | WAIVER |
postConcessionApplied | Income account | Fee Receivables (1003) | CONCESSION |
postRefund | Income account | Cash/Bank | REFUND |
reverseEntry | Flip original credits | Flip original debits | (original sourceType) |
Key invariants:
- Balance assertion:
sum(debit) === sum(credit)is checked before every write. A mismatch throws immediately. - Idempotency: All methods check for existing entries with the same
(sourceType, sourceId)before creating. Safe for webhook retries. - Double-check in transaction: The idempotency check runs again inside the
$transactionto handle concurrent calls. - Atomic entry number:
JE-{YYYY}-{00001}format, generated viaInstitution.journalEntryCounterincrement inside the same transaction.
Cash vs. Bank routing:
CASHmode -> account code1001(Cash in Hand)- All other modes -> account code
1002(Bank Account)
Income account mapping:
The service maps fee heads to income accounts via:
- Explicit
FeeHead.glAccountIdbinding (preferred) - Name/category pattern matching:
transport-> 4002,hostel-> 4003,exam-> 4004,tuition-> 4001 - Fallback:
4005(Other Fee Income)
9.4 ConcessionService
Location: src/services/finance/ConcessionService.ts
Rule CRUD, student application, approval/rejection, sibling auto-detection. See Section 5 for full API.
9.5 ReceiptGenerator
Location: src/services/finance/ReceiptGenerator.ts
generateAndEmail(paymentId, options?)
- Atomic receipt number via
Institution.receiptCounterincrement - Resolves HTML template from
DocumentTemplate(kind=RECEIPT, isDefault=true) - Falls back to hardcoded default template if none exists
- Builds merge-data scope (institution, student, payment, lines, formatted amounts)
- Renders HTML via
TemplateRenderer - Converts HTML to PDF via jsPDF
- Uploads PDF to Cloudflare R2 at
finance/receipts/{institutionId}/{year}/{receiptNo}.pdf - Creates
Receiptrow and links to Payment - Emails primary parent contact with PDF attachment via Resend
Receipt number format: {prefix}-{fyYear}-{000001}
Force mode ({ force: true }): Re-renders PDF and re-emails using the existing receipt number. Used by the "Resend" action.
9.6 FeeStructureService
Location: src/services/finance/FeeStructureService.ts
updateFeePlan(planId, data) -- Updates non-structural fields. Throws LockedPlanError (409) if the plan has isLocked: true (invoices have been generated).
copyFeePlanForNextYear(sourcePlanId, targetAcademicYearId, newName?) -- Clones a locked plan with all items, instalments, and class links. Sets isLocked: false on the copy.
9.7 LmsEnrollmentSyncService
Location: src/services/finance/LmsEnrollmentSyncService.ts
Invalidates OptiLearn revenue dashboard Redis cache keys (optilearn:analytics:revenue:{institutionId}:*) when a payment with sourceType: "LMS_ENROLLMENT" captures. Uses Redis SCAN to avoid blocking on large key sets.
10. BullMQ Queues
Queue definition: src/lib/finance/queue.ts
Worker: scripts/finance-worker.ts
10.1 Queue Configuration
const QUEUE_DEFAULTS = {
attempts: 3,
backoff: { type: "exponential", delay: 5000 },
removeOnComplete: { count: 200 },
removeOnFail: { count: 100 },
};
const QUEUE_PREFIX = "opticrm-finance";All queues share the same IORedis connection and are namespaced under opticrm-finance: to separate from OptiLearn's lms:* queues on the shared Redis.
10.2 Invoices Queue
Name: opticrm-finance:invoices
Concurrency: 4
Job name: bulkGenerateInvoices
Payload:
interface BulkInvoiceGenerationPayload {
institutionId: string;
classId: string;
sectionId?: string;
academicYearId: string;
studentIds: string[];
}The worker processes students in batches of 50, calling InvoiceService.generateForStudent for each. Progress is reported via job.updateProgress().
10.3 Receipts Queue
Name: opticrm-finance:receipts
Concurrency: 8
Job name: renderAndEmailReceipt
Payload:
interface ReceiptRenderPayload {
institutionId: string;
paymentId: string;
}10.4 Producer Helpers
import { enqueueBulkInvoiceGeneration, enqueueReceiptRender } from "@/lib/finance/queue";
// Bulk invoices — returns jobId for polling
const jobId = await enqueueBulkInvoiceGeneration({
institutionId: "inst_1",
classId: "class_1",
academicYearId: "year_1",
studentIds: ["s1", "s2", "s3"],
});
// Receipt render — fire and forget
await enqueueReceiptRender({
institutionId: "inst_1",
paymentId: "pay_1",
});Both return null if Redis is unavailable (dev fallback: run inline).
10.5 Deployment
Deploy the worker as a separate long-lived process:
npx tsx scripts/finance-worker.tsIn Coolify, add a second service for this repo with CMD ["npx","tsx","scripts/finance-worker.ts"].
11. Double-Entry Accounting Model
11.1 Schema
JournalEntry (header):
| Field | Type | Description |
|---|---|---|
id | String | CUID primary key |
institutionId | String | Multi-tenant isolation |
entryNumber | String | JE-{YYYY}-{00001} format, gap-free |
date | DateTime | Posting date (UTC) |
narration | String | Human-readable description |
entryType | String | AUTO, MANUAL, ADJUSTMENT, REVERSAL |
sourceType | String | INVOICE, PAYMENT, WAIVER, CONCESSION, REFUND |
sourceId | String | ID of the source record for audit trail |
isReversed | Boolean | True if this entry has been reversed |
reversalOfId | String? | Points to the original entry (for REVERSAL entries) |
createdById | String | User who created (system user for AUTO) |
JournalLine (per-entry detail):
| Field | Type | Description |
|---|---|---|
id | String | CUID primary key |
journalEntryId | String | FK to header |
glAccountId | String | FK to GLAccount |
debit | Decimal | Debit amount (0 if credit line) |
credit | Decimal | Credit amount (0 if debit line) |
description | String | Line-level narration |
11.2 Standard Chart of Accounts
Seeded for every institution via src/lib/finance/seed-chart-of-accounts.ts:
| Code | Name | Type |
|---|---|---|
| 1001 | Cash in Hand | ASSET |
| 1002 | Bank Account (Primary) | ASSET |
| 1003 | Fee Receivables | ASSET |
| 1004 | Security Deposits Receivable | ASSET |
| 2001 | Accounts Payable | LIABILITY |
| 2002 | Student Deposits Payable | LIABILITY |
| 2003 | GST Payable (Output) | LIABILITY |
| 4001 | Fee Income (Tuition) | INCOME |
| 4002 | Transport Fee Income | INCOME |
| 4003 | Hostel Fee Income | INCOME |
| 4004 | Examination Fee Income | INCOME |
| 4005 | Other Fee Income | INCOME |
| 5001 | Staff Salaries | EXPENSE |
| 5002 | Utilities & Electricity | EXPENSE |
| 5003 | Building Maintenance | EXPENSE |
| 5004 | Stationery & Supplies | EXPENSE |
| 5005 | Events & Activities | EXPENSE |
System accounts are marked isSystemAccount: true and cannot be deleted from the UI.
11.3 Entry Types
- AUTO -- Created automatically by services (invoice generation, payment capture)
- MANUAL -- Created by admin through the journal entry UI
- ADJUSTMENT -- Waivers, concessions, write-offs
- REVERSAL -- Created when reversing an entry (cheque bounce, invoice cancellation)
11.4 Source Tracking
Every journal entry links back to its source via sourceType + sourceId. This provides a full audit trail:
INVOICE:clx_inv_1 -> Invoice created JE
PAYMENT:clx_pay_1 -> Payment received JE
WAIVER:clx_waiver_1 -> Fee waiver JE
CONCESSION:clx_conc_1 -> Concession applied JE
REFUND:clx_ref_1 -> Refund JE11.5 Reversal Mechanism
When an entry is reversed:
- Original entry's
isReversedflag is set totrue - A new REVERSAL entry is created with
reversalOfIdpointing to the original - All debit/credit amounts are flipped (debits become credits and vice versa)
- The reversal narration references the original:
"Reversal: {reason} (of {original narration})"
11.6 Balance Assertion
Before every journal entry commit, the service asserts:
sum(debit across all lines) === sum(credit across all lines)If this fails, the service throws a Double-entry violation error immediately. This is a hard fail -- no entry is created.
12. Multi-Line Invoice Model (Phase 1C.3)
12.1 Schema
Invoice (header):
| Field | Type | Description |
|---|---|---|
invoiceNo | String | {prefix}-{fyYear}-{000001} format |
studentId | String | FK to Student |
instalmentId | String | FK to FeeInstalment |
feeHeadId | String? | Primary head (for backward compat) |
amount | Decimal | Sum of line amounts (gross) |
concessionAmount | Decimal | Sum of line concessions |
totalTax | Decimal | Sum of line taxes |
cgst | Decimal | Sum of CGST across lines |
sgst | Decimal | Sum of SGST across lines |
igst | Decimal | Sum of IGST across lines |
netAmount | Decimal | Sum of line totals (after concession + tax) |
paidAmount | Decimal | Running total of successful payments |
status | Enum | PUBLISHED, PARTIALLY_PAID, PAID, OVERDUE, CANCELLED, VOID, WAIVED, WRITTEN_OFF |
InvoiceLine (per fee head):
| Field | Type | Description |
|---|---|---|
sequence | Int | Display order |
feeHeadId | String | FK to FeeHead |
description | String | Fee head name |
amount | Decimal | Gross amount for this head |
concessionAmount | Decimal | Concession applied to this head |
taxable | Decimal | amount - concessionAmount |
taxRate | Decimal | GST rate (percentage, e.g., 18.00) |
cgst | Decimal | Central GST (taxable * rate / 200) |
sgst | Decimal | State GST (taxable * rate / 200) |
igst | Decimal | Interstate GST (0 for intra-state) |
totalTax | Decimal | cgst + sgst + igst |
lineTotal | Decimal | taxable + totalTax |
12.2 Invariants
These are enforced by InvoiceService at write time and verified by the invoice-line-consistency test:
SUM(line.amount) == invoice.amount (± 1 paisa)
SUM(line.concessionAmount) == invoice.concessionAmount (± 1 paisa)
SUM(line.totalTax) == invoice.totalTax (± 1 paisa)
SUM(line.lineTotal) == invoice.netAmount (± 1 paisa)Per-line consistency:
line.taxable == line.amount - line.concessionAmount
line.totalTax == line.cgst + line.sgst + line.igst
line.lineTotal == line.taxable + line.totalTax12.3 GST Computation
GST is charged on the post-concession taxable value per GST Rule 15(1):
taxable = amount - concessionAmount
totalTax = taxable * (taxRate / 100)
cgst = totalTax / 2 (intra-state)
sgst = totalTax / 2 (intra-state)
igst = 0 (intra-state)12.4 Invoice Number Format
{prefix}-{fyYear}-{000001}
prefix-- per-institution configurable (Institution.invoicePrefix, defaultINV)fyYear-- financial year start year (April-based in India, configurable viaInstitution.financialYearStart)- Sequence -- atomic counter via
Institution.invoiceCounterincrement
Example: INV-2026-000042
12.5 Backward Compatibility
Legacy single-line invoices (pre-Phase 1C.3) have no InvoiceLine rows. The system handles both:
feeHeadIdon the header is preserved for backward compat- JournalPostingService falls back to header
feeHeadIdwhen no lines exist - Line consistency tests skip invoices with zero lines
13. Testing
All finance tests use Vitest and are located in src/__tests__/finance/.
# Run all finance tests
npx vitest run src/__tests__/finance/
# Run a specific test
npx vitest run src/__tests__/finance/double-entry.test.ts13.1 Test Files
| File | What It Verifies |
|---|---|
concession-cap.test.ts | calculateConcessionAmount clamping: percentage vs flat vs full waiver, maxAmount cap, negative clamping to zero, discount cannot exceed plan total |
invoice-total-consistency.test.ts | For every non-cancelled invoice: netAmount == totalAmount - concessionAmount + totalTax, paidSum <= netAmount, paidSum == sum(Payment.amount) |
invoice-line-consistency.test.ts | Phase 1C.3 line-level invariants: header totals match sum of line values, each line is internally consistent (taxable, tax, lineTotal) |
receipt-number-concurrency.test.ts | Concurrent nextReceiptNumber() calls produce unique, gap-free, monotonically increasing numbers via Postgres atomic counter |
double-entry.test.ts | Every JournalEntry has sum(debit) === sum(credit). Queries the live DB to catch both service-created and manual/migration entries |
multi-tenant-isolation.test.ts | Services for institution A cannot read/mutate records from institution B, even with raw IDs. Validates institutionId filtering on Invoice fetch and JE posting |
idempotency.test.ts | postPaymentReceived and postInvoiceCreated are idempotent (second call returns existing, no duplicate). reverseEntry on already-reversed throws |
fee-head-applicability.test.ts | Full combinatorial matrix of feeHeadAppliesToStudent: applicableTo (ALL/DAY/RESIDENTIAL/RTE) x requiresAssignment (null/TRANSPORT/HOSTEL) x student flags |
13.2 Test Database
Tests that query the database (double-entry, invoice-total-consistency, invoice-line-consistency, receipt-number-concurrency, multi-tenant-isolation, idempotency) run against whatever DATABASE_URL the test process is pointed at. Use a dedicated test database or the dev database.
Tests that are pure-function (concession-cap, fee-head-applicability) require no database connection.
Appendix: Error Codes
| Code | HTTP | Meaning |
|---|---|---|
UNAUTHORIZED | 401 | No valid session |
FORBIDDEN | 403 | User lacks required permission |
BAD_REQUEST | 400 | Invalid or missing request body fields |
NOT_FOUND | 404 | Record not found or not accessible |
GENERATE_FAILED | 400 | Invoice generation failed (no plan assigned, etc.) |
PAYMENT_FAILED | 400 | Payment recording failed (amount exceeds outstanding, invoice cancelled, etc.) |
BOUNCE_FAILED | 400 | Cheque bounce failed (not a cheque, already bounced, etc.) |
CANCEL_FAILED | 400 | Invoice cancel failed (already PAID, already cancelled, etc.) |
GEN_FAILED | 500 | Receipt generation failed |
RESEND_FAILED | 500 | Receipt resend failed |