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/invoices

Query Parameters:

ParameterTypeDescription
studentIdstringFilter by student ID
statusstringPENDING, PARTIAL, PAID, OVERDUE, PUBLISHED, PARTIALLY_PAID, CANCELLED, VOID, or the virtual outstanding (includes PENDING + PARTIAL + OVERDUE)
overdueMinnumberMinimum days overdue (client-side filter)
overdueMaxnumberMaximum 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/json

Permission: create on fee_collection

Request Body:

{
  "studentId": "clx_student_id",
  "academicYearId": "clx_year_id",
  "instalmentId": "clx_instalment_id"
}
  • instalmentId is 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/json

Permission: 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"
}
  • sectionId is 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/json

Permission: 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}/summary

Returns 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/json

Permission: 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/json

Permission: 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" — isChequeBounced is already true

2.3 Portal: Create Razorpay Order

POST /api/portal/payments
Content-Type: application/json

Auth: 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/json

Auth: 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}/resend

Force 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-heads

Returns 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/json

Request 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_1

Soft-deletes by setting isActive: false.

4.5 List Fee Plans

GET /api/finance/fee-plans

Returns 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/json

Permission: 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/json

Assigns 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_1

Soft-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 = false

5.5 ConcessionService.listRules

const rules = await svc.listRules("clx_year_1");
// Ordered by: isActive DESC, kind ASC, name ASC

5.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:

  1. Find all parent Contacts linked to the student via ContactStudentLink
  2. Find all other active students linked to those same parents
  3. Count unique sibling student IDs
  4. Match against SIBLING rules (ordered by discountValue descending)
  5. 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 5000

Clamping rules:

  • FULL_WAIVER: discount = feePlanTotal (entire amount waived)
  • PERCENTAGE: discount = feePlanTotal * discountValue / 100
  • FLAT: discount = discountValue
  • Result is clamped to [0, feePlanTotal]
  • If maxAmount is set, result is further clamped to [0, maxAmount]

6. Academic Years API

6.1 List Academic Years

GET /api/finance/academic-years

Permission: 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:

  1. Platform-level secret (RAZORPAY_WEBHOOK_SECRET env var) -- always checked
  2. 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:

  1. Checks idempotency (skips if razorpayPaymentId already has a SUCCESS payment)
  2. Updates the pending Payment to SUCCESS
  3. Updates the Invoice's paidAmount and status (PAID or PARTIALLY_PAID)
  4. Posts a journal entry via JournalPostingService.postPaymentReceived
  5. Enqueues receipt generation via BullMQ (fire-and-forget)
  6. Fires ObjectHook AFTER_CREATE hooks (fire-and-forget)
  7. 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-gateway

Response:

{
  "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/json

Permission: 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/json

Creates 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:

  1. Fetch plan -- Loads the StudentFeePlan with items, fee heads, and instalments
  2. Lock plan -- Sets isLocked: true on first generation (prevents structural edits)
  3. Load student context -- Fetches isRteStudent, active StudentTransport, active HostelAllotment
  4. Check existing -- Finds already-created invoices to skip (idempotency)
  5. Auto-detect sibling concession -- Calls ConcessionService.autoDetectAndApplySiblingConcession
  6. Load active concessions -- Fetches all ACTIVE/APPROVED StudentConcession rows
  7. Filter heads by applicability -- Uses feeHeadAppliesToStudent() pure function:
    • applicableTo: "DAY" -- excludes RTE and hostel students
    • applicableTo: "RESIDENTIAL" -- only hostel students
    • applicableTo: "RTE" -- only RTE students
    • requiresAssignment: "TRANSPORT" -- only students with active transport
    • requiresAssignment: "HOSTEL" -- only students with active hostel allotment
    • autoAddToInvoice: false -- always excluded
  8. Group by instalment -- One Invoice per instalment, with one InvoiceLine per applicable fee head
  9. Compute per-line values -- Amount, concession, taxable, GST (CGST/SGST/IGST), line total
  10. Materialise invoice -- Creates Invoice header + InvoiceLine rows
  11. Post journal entry -- JournalPostingService.postInvoiceCreated
  12. Fire hooks -- runStandardEntityAfterHooks("Invoice", "AFTER_CREATE", ...)

bulkGenerateForClass(input)

  1. Finds all active enrolments in the class/section
  2. Enqueues a BullMQ job on opticrm-finance:invoices
  3. Returns { queued, jobId } for polling

cancelInvoice(invoiceId, reason)

  1. Validates invoice exists and is not PAID/CANCELLED/VOID
  2. Reverses the original journal entry via JournalPostingService.reverseEntry
  3. Updates invoice status to CANCELLED with reason appended to notes
  4. 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:

MethodDebitCreditSource Type
postInvoiceCreatedFee Receivables (1003)Per-head Income (4001-4005)INVOICE
postPaymentReceivedCash (1001) or Bank (1002)Fee Receivables (1003)PAYMENT
postFeeWaiverIncome accountFee Receivables (1003)WAIVER
postConcessionAppliedIncome accountFee Receivables (1003)CONCESSION
postRefundIncome accountCash/BankREFUND
reverseEntryFlip original creditsFlip 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 $transaction to handle concurrent calls.
  • Atomic entry number: JE-{YYYY}-{00001} format, generated via Institution.journalEntryCounter increment inside the same transaction.

Cash vs. Bank routing:

  • CASH mode -> account code 1001 (Cash in Hand)
  • All other modes -> account code 1002 (Bank Account)

Income account mapping:

The service maps fee heads to income accounts via:

  1. Explicit FeeHead.glAccountId binding (preferred)
  2. Name/category pattern matching: transport -> 4002, hostel -> 4003, exam -> 4004, tuition -> 4001
  3. 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?)

  1. Atomic receipt number via Institution.receiptCounter increment
  2. Resolves HTML template from DocumentTemplate (kind=RECEIPT, isDefault=true)
  3. Falls back to hardcoded default template if none exists
  4. Builds merge-data scope (institution, student, payment, lines, formatted amounts)
  5. Renders HTML via TemplateRenderer
  6. Converts HTML to PDF via jsPDF
  7. Uploads PDF to Cloudflare R2 at finance/receipts/{institutionId}/{year}/{receiptNo}.pdf
  8. Creates Receipt row and links to Payment
  9. 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.ts

In 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):

FieldTypeDescription
idStringCUID primary key
institutionIdStringMulti-tenant isolation
entryNumberStringJE-{YYYY}-{00001} format, gap-free
dateDateTimePosting date (UTC)
narrationStringHuman-readable description
entryTypeStringAUTO, MANUAL, ADJUSTMENT, REVERSAL
sourceTypeStringINVOICE, PAYMENT, WAIVER, CONCESSION, REFUND
sourceIdStringID of the source record for audit trail
isReversedBooleanTrue if this entry has been reversed
reversalOfIdString?Points to the original entry (for REVERSAL entries)
createdByIdStringUser who created (system user for AUTO)

JournalLine (per-entry detail):

FieldTypeDescription
idStringCUID primary key
journalEntryIdStringFK to header
glAccountIdStringFK to GLAccount
debitDecimalDebit amount (0 if credit line)
creditDecimalCredit amount (0 if debit line)
descriptionStringLine-level narration

11.2 Standard Chart of Accounts

Seeded for every institution via src/lib/finance/seed-chart-of-accounts.ts:

CodeNameType
1001Cash in HandASSET
1002Bank Account (Primary)ASSET
1003Fee ReceivablesASSET
1004Security Deposits ReceivableASSET
2001Accounts PayableLIABILITY
2002Student Deposits PayableLIABILITY
2003GST Payable (Output)LIABILITY
4001Fee Income (Tuition)INCOME
4002Transport Fee IncomeINCOME
4003Hostel Fee IncomeINCOME
4004Examination Fee IncomeINCOME
4005Other Fee IncomeINCOME
5001Staff SalariesEXPENSE
5002Utilities & ElectricityEXPENSE
5003Building MaintenanceEXPENSE
5004Stationery & SuppliesEXPENSE
5005Events & ActivitiesEXPENSE

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 JE

11.5 Reversal Mechanism

When an entry is reversed:

  1. Original entry's isReversed flag is set to true
  2. A new REVERSAL entry is created with reversalOfId pointing to the original
  3. All debit/credit amounts are flipped (debits become credits and vice versa)
  4. 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):

FieldTypeDescription
invoiceNoString{prefix}-{fyYear}-{000001} format
studentIdStringFK to Student
instalmentIdStringFK to FeeInstalment
feeHeadIdString?Primary head (for backward compat)
amountDecimalSum of line amounts (gross)
concessionAmountDecimalSum of line concessions
totalTaxDecimalSum of line taxes
cgstDecimalSum of CGST across lines
sgstDecimalSum of SGST across lines
igstDecimalSum of IGST across lines
netAmountDecimalSum of line totals (after concession + tax)
paidAmountDecimalRunning total of successful payments
statusEnumPUBLISHED, PARTIALLY_PAID, PAID, OVERDUE, CANCELLED, VOID, WAIVED, WRITTEN_OFF

InvoiceLine (per fee head):

FieldTypeDescription
sequenceIntDisplay order
feeHeadIdStringFK to FeeHead
descriptionStringFee head name
amountDecimalGross amount for this head
concessionAmountDecimalConcession applied to this head
taxableDecimalamount - concessionAmount
taxRateDecimalGST rate (percentage, e.g., 18.00)
cgstDecimalCentral GST (taxable * rate / 200)
sgstDecimalState GST (taxable * rate / 200)
igstDecimalInterstate GST (0 for intra-state)
totalTaxDecimalcgst + sgst + igst
lineTotalDecimaltaxable + 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.totalTax

12.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, default INV)
  • fyYear -- financial year start year (April-based in India, configurable via Institution.financialYearStart)
  • Sequence -- atomic counter via Institution.invoiceCounter increment

Example: INV-2026-000042

12.5 Backward Compatibility

Legacy single-line invoices (pre-Phase 1C.3) have no InvoiceLine rows. The system handles both:

  • feeHeadId on the header is preserved for backward compat
  • JournalPostingService falls back to header feeHeadId when 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.ts

13.1 Test Files

FileWhat It Verifies
concession-cap.test.tscalculateConcessionAmount clamping: percentage vs flat vs full waiver, maxAmount cap, negative clamping to zero, discount cannot exceed plan total
invoice-total-consistency.test.tsFor every non-cancelled invoice: netAmount == totalAmount - concessionAmount + totalTax, paidSum <= netAmount, paidSum == sum(Payment.amount)
invoice-line-consistency.test.tsPhase 1C.3 line-level invariants: header totals match sum of line values, each line is internally consistent (taxable, tax, lineTotal)
receipt-number-concurrency.test.tsConcurrent nextReceiptNumber() calls produce unique, gap-free, monotonically increasing numbers via Postgres atomic counter
double-entry.test.tsEvery JournalEntry has sum(debit) === sum(credit). Queries the live DB to catch both service-created and manual/migration entries
multi-tenant-isolation.test.tsServices 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.tspostPaymentReceived and postInvoiceCreated are idempotent (second call returns existing, no duplicate). reverseEntry on already-reversed throws
fee-head-applicability.test.tsFull 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

CodeHTTPMeaning
UNAUTHORIZED401No valid session
FORBIDDEN403User lacks required permission
BAD_REQUEST400Invalid or missing request body fields
NOT_FOUND404Record not found or not accessible
GENERATE_FAILED400Invoice generation failed (no plan assigned, etc.)
PAYMENT_FAILED400Payment recording failed (amount exceeds outstanding, invoice cancelled, etc.)
BOUNCE_FAILED400Cheque bounce failed (not a cheque, already bounced, etc.)
CANCEL_FAILED400Invoice cancel failed (already PAID, already cancelled, etc.)
GEN_FAILED500Receipt generation failed
RESEND_FAILED500Receipt resend failed