@studious-lms/server 1.23.0 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +0 -1
- package/data/curriculum/nepal-cdc-g4-g5/README.md +90 -0
- package/data/curriculum/nepal-cdc-g4-g5/manifest.json +97 -0
- package/data/curriculum/nepal-cdc-g4-g5/proficiency-scale.json +76 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/english.json +6936 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/health-physical-creative-arts.json +3401 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/mathematics.json +1511 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/nepali.json +7928 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/science-technology.json +2420 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/social-studies.json +2177 -0
- package/dist/lib/assignmentTypeDefaults.d.ts +41 -0
- package/dist/lib/assignmentTypeDefaults.d.ts.map +1 -0
- package/dist/lib/assignmentTypeDefaults.js +167 -0
- package/dist/lib/assignmentTypeDefaults.js.map +1 -0
- package/dist/lib/config/env.d.ts +0 -8
- package/dist/lib/config/env.d.ts.map +1 -1
- package/dist/lib/config/env.js +2 -5
- package/dist/lib/config/env.js.map +1 -1
- package/dist/lib/feePdf.d.ts +19 -0
- package/dist/lib/feePdf.d.ts.map +1 -0
- package/dist/lib/feePdf.js +167 -0
- package/dist/lib/feePdf.js.map +1 -0
- package/dist/lib/feePdfFormat.d.ts +4 -0
- package/dist/lib/feePdfFormat.d.ts.map +1 -0
- package/dist/lib/feePdfFormat.js +16 -0
- package/dist/lib/feePdfFormat.js.map +1 -0
- package/dist/lib/idCardPdf.d.ts +26 -0
- package/dist/lib/idCardPdf.d.ts.map +1 -0
- package/dist/lib/idCardPdf.js +272 -0
- package/dist/lib/idCardPdf.js.map +1 -0
- package/dist/lib/pdfLetterhead.d.ts +41 -0
- package/dist/lib/pdfLetterhead.d.ts.map +1 -0
- package/dist/lib/pdfLetterhead.js +86 -0
- package/dist/lib/pdfLetterhead.js.map +1 -0
- package/dist/lib/transcriptPdf.d.ts +55 -0
- package/dist/lib/transcriptPdf.d.ts.map +1 -0
- package/dist/lib/transcriptPdf.js +303 -0
- package/dist/lib/transcriptPdf.js.map +1 -0
- package/dist/models/academicCalendar.d.ts +198 -0
- package/dist/models/academicCalendar.d.ts.map +1 -0
- package/dist/models/academicCalendar.js +106 -0
- package/dist/models/academicCalendar.js.map +1 -0
- package/dist/models/agenda.d.ts +8 -0
- package/dist/models/agenda.d.ts.map +1 -1
- package/dist/models/announcement.d.ts +2 -0
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/assignment.d.ts +66 -0
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/attendance.d.ts +659 -21
- package/dist/models/attendance.d.ts.map +1 -1
- package/dist/models/attendance.js +547 -75
- package/dist/models/attendance.js.map +1 -1
- package/dist/models/auth.d.ts +2 -0
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/class.d.ts +12 -0
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +3 -2
- package/dist/models/class.js.map +1 -1
- package/dist/models/event.d.ts +16 -0
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/fees.d.ts +554 -0
- package/dist/models/fees.d.ts.map +1 -0
- package/dist/models/fees.js +278 -0
- package/dist/models/fees.js.map +1 -0
- package/dist/models/file.d.ts +29 -0
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/folder.d.ts +2 -0
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +2 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/marketplace.d.ts +180 -0
- package/dist/models/marketplace.d.ts.map +1 -0
- package/dist/models/marketplace.js +116 -0
- package/dist/models/marketplace.js.map +1 -0
- package/dist/models/moduleItem.d.ts +308 -0
- package/dist/models/moduleItem.d.ts.map +1 -0
- package/dist/models/moduleItem.js +93 -0
- package/dist/models/moduleItem.js.map +1 -0
- package/dist/models/newtonChat.d.ts +1 -0
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/school.d.ts +18 -0
- package/dist/models/school.d.ts.map +1 -1
- package/dist/models/school.js +7 -2
- package/dist/models/school.js.map +1 -1
- package/dist/models/section.d.ts +4 -0
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/worksheet.d.ts +19 -0
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +2 -2
- package/dist/models/worksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +12024 -7778
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +12 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/academicCalendar.d.ts +229 -0
- package/dist/routers/academicCalendar.d.ts.map +1 -0
- package/dist/routers/academicCalendar.js +75 -0
- package/dist/routers/academicCalendar.js.map +1 -0
- package/dist/routers/admin.d.ts +2 -0
- package/dist/routers/admin.d.ts.map +1 -1
- package/dist/routers/agenda.d.ts +8 -0
- package/dist/routers/agenda.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts +75 -0
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +10 -2
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/attendance.d.ts +103 -0
- package/dist/routers/attendance.d.ts.map +1 -1
- package/dist/routers/attendance.js +8 -5
- package/dist/routers/attendance.js.map +1 -1
- package/dist/routers/class.d.ts +27 -0
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +6 -2
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/event.d.ts +8 -0
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/fees.d.ts +513 -0
- package/dist/routers/fees.d.ts.map +1 -0
- package/dist/routers/fees.js +88 -0
- package/dist/routers/fees.js.map +1 -0
- package/dist/routers/file.d.ts +2 -0
- package/dist/routers/file.d.ts.map +1 -1
- package/dist/routers/gradebook.d.ts +4 -1
- package/dist/routers/gradebook.d.ts.map +1 -1
- package/dist/routers/marketplace.d.ts +371 -0
- package/dist/routers/marketplace.d.ts.map +1 -0
- package/dist/routers/marketplace.js +87 -0
- package/dist/routers/marketplace.js.map +1 -0
- package/dist/routers/moduleItem.d.ts +263 -0
- package/dist/routers/moduleItem.d.ts.map +1 -0
- package/dist/routers/moduleItem.js +61 -0
- package/dist/routers/moduleItem.js.map +1 -0
- package/dist/routers/parent.d.ts +170 -0
- package/dist/routers/parent.d.ts.map +1 -0
- package/dist/routers/parent.js +49 -0
- package/dist/routers/parent.js.map +1 -0
- package/dist/routers/school.d.ts +363 -7
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +110 -3
- package/dist/routers/school.js.map +1 -1
- package/dist/routers/worksheet.d.ts +27 -0
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +10 -3
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/services/academicCalendar.d.ts +173 -0
- package/dist/services/academicCalendar.d.ts.map +1 -0
- package/dist/services/academicCalendar.js +242 -0
- package/dist/services/academicCalendar.js.map +1 -0
- package/dist/services/adminInstitution.d.ts +2 -0
- package/dist/services/adminInstitution.d.ts.map +1 -1
- package/dist/services/agenda.d.ts +8 -0
- package/dist/services/agenda.d.ts.map +1 -1
- package/dist/services/assignment.d.ts +75 -0
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +91 -12
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/attendance.d.ts +327 -10
- package/dist/services/attendance.d.ts.map +1 -1
- package/dist/services/attendance.js +389 -25
- package/dist/services/attendance.js.map +1 -1
- package/dist/services/attendanceMerge.d.ts +53 -0
- package/dist/services/attendanceMerge.d.ts.map +1 -0
- package/dist/services/attendanceMerge.js +87 -0
- package/dist/services/attendanceMerge.js.map +1 -0
- package/dist/services/attendanceRate.d.ts +24 -0
- package/dist/services/attendanceRate.d.ts.map +1 -0
- package/dist/services/attendanceRate.js +31 -0
- package/dist/services/attendanceRate.js.map +1 -0
- package/dist/services/attendanceStats.d.ts +22 -0
- package/dist/services/attendanceStats.d.ts.map +1 -0
- package/dist/services/attendanceStats.js +45 -0
- package/dist/services/attendanceStats.js.map +1 -0
- package/dist/services/class.d.ts +34 -2
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +27 -4
- package/dist/services/class.js.map +1 -1
- package/dist/services/event.d.ts +8 -0
- package/dist/services/event.d.ts.map +1 -1
- package/dist/services/fees.d.ts +422 -0
- package/dist/services/fees.d.ts.map +1 -0
- package/dist/services/fees.js +330 -0
- package/dist/services/fees.js.map +1 -0
- package/dist/services/feesAuthz.d.ts +46 -0
- package/dist/services/feesAuthz.d.ts.map +1 -0
- package/dist/services/feesAuthz.js +49 -0
- package/dist/services/feesAuthz.js.map +1 -0
- package/dist/services/file.d.ts +4 -0
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/gradebook.d.ts +18 -1
- package/dist/services/gradebook.d.ts.map +1 -1
- package/dist/services/gradebook.js +57 -2
- package/dist/services/gradebook.js.map +1 -1
- package/dist/services/idCard.d.ts +11 -0
- package/dist/services/idCard.d.ts.map +1 -0
- package/dist/services/idCard.js +122 -0
- package/dist/services/idCard.js.map +1 -0
- package/dist/services/marketplace.d.ts +301 -0
- package/dist/services/marketplace.d.ts.map +1 -0
- package/dist/services/marketplace.js +631 -0
- package/dist/services/marketplace.js.map +1 -0
- package/dist/services/moduleItem.d.ts +196 -0
- package/dist/services/moduleItem.d.ts.map +1 -0
- package/dist/services/moduleItem.js +204 -0
- package/dist/services/moduleItem.js.map +1 -0
- package/dist/services/notification.d.ts +58 -0
- package/dist/services/notification.d.ts.map +1 -1
- package/dist/services/notification.js +30 -2
- package/dist/services/notification.js.map +1 -1
- package/dist/services/parentPortal.d.ts +131 -0
- package/dist/services/parentPortal.d.ts.map +1 -0
- package/dist/services/parentPortal.js +314 -0
- package/dist/services/parentPortal.js.map +1 -0
- package/dist/services/school.d.ts +28 -3
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +98 -54
- package/dist/services/school.js.map +1 -1
- package/dist/services/studentImport.d.ts +34 -0
- package/dist/services/studentImport.d.ts.map +1 -0
- package/dist/services/studentImport.js +249 -0
- package/dist/services/studentImport.js.map +1 -0
- package/dist/services/transcript.d.ts +60 -0
- package/dist/services/transcript.d.ts.map +1 -0
- package/dist/services/transcript.js +231 -0
- package/dist/services/transcript.js.map +1 -0
- package/dist/services/worksheet.d.ts +22 -0
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +94 -2
- package/dist/services/worksheet.js.map +1 -1
- package/dist/trpc.d.ts +1 -1
- package/dist/trpc.d.ts.map +1 -1
- package/dist/trpc.js +5 -5
- package/dist/trpc.js.map +1 -1
- package/dist/utils/adminPanelAccess.d.ts +4 -5
- package/dist/utils/adminPanelAccess.d.ts.map +1 -1
- package/dist/utils/adminPanelAccess.js +12 -34
- package/dist/utils/adminPanelAccess.js.map +1 -1
- package/package.json +2 -1
- package/prisma/migrations/20260804120000_add_attendance_records_and_excusals/migration.sql +114 -0
- package/prisma/migrations/20260804120100_add_fee_plans_invoices_payments/migration.sql +101 -0
- package/prisma/migrations/20260804120200_add_marketplace_listings/migration.sql +54 -0
- package/prisma/migrations/20260804120300_content_platform_b3_b9/migration.sql +63 -0
- package/prisma/migrations/20260804120400_c_academic_ops/migration.sql +77 -0
- package/prisma/schema.prisma +371 -11
- package/scripts/import-nepal-cdc-curriculum.ts +402 -0
- package/src/lib/assignmentTypeDefaults.ts +222 -0
- package/src/lib/config/env.ts +0 -3
- package/src/lib/feePdf.ts +199 -0
- package/src/lib/feePdfFormat.ts +14 -0
- package/src/lib/idCardPdf.ts +336 -0
- package/src/lib/pdfLetterhead.ts +124 -0
- package/src/lib/transcriptPdf.ts +502 -0
- package/src/models/academicCalendar.ts +139 -0
- package/src/models/attendance.ts +702 -73
- package/src/models/class.ts +1 -0
- package/src/models/fees.ts +349 -0
- package/src/models/marketplace.ts +161 -0
- package/src/models/moduleItem.ts +125 -0
- package/src/models/school.ts +6 -0
- package/src/models/worksheet.ts +2 -0
- package/src/routers/_app.ts +10 -0
- package/src/routers/academicCalendar.ts +103 -0
- package/src/routers/assignment.ts +8 -0
- package/src/routers/attendance.ts +7 -3
- package/src/routers/class.ts +4 -0
- package/src/routers/fees.ts +126 -0
- package/src/routers/marketplace.ts +134 -0
- package/src/routers/moduleItem.ts +91 -0
- package/src/routers/parent.ts +68 -0
- package/src/routers/school.ts +149 -1
- package/src/routers/worksheet.ts +11 -0
- package/src/services/academicCalendar.ts +327 -0
- package/src/services/assignment.ts +122 -12
- package/src/services/attendance.ts +552 -26
- package/src/services/attendanceMerge.ts +139 -0
- package/src/services/attendanceRate.ts +41 -0
- package/src/services/attendanceStats.ts +63 -0
- package/src/services/class.ts +26 -2
- package/src/services/fees.ts +447 -0
- package/src/services/feesAuthz.ts +73 -0
- package/src/services/gradebook.ts +70 -0
- package/src/services/idCard.ts +147 -0
- package/src/services/marketplace.ts +790 -0
- package/src/services/moduleItem.ts +298 -0
- package/src/services/notification.ts +43 -0
- package/src/services/parentPortal.ts +367 -0
- package/src/services/school.ts +100 -54
- package/src/services/studentImport.ts +285 -0
- package/src/services/transcript.ts +301 -0
- package/src/services/worksheet.ts +111 -0
- package/src/trpc.ts +3 -3
- package/src/utils/adminPanelAccess.ts +12 -35
- package/tests/unit/assignmentTypeDefaults.test.ts +52 -0
- package/tests/unit/lib/feePdf.test.ts +15 -0
- package/tests/unit/lib/idCardPdf.test.ts +40 -0
- package/tests/unit/lib/transcriptPdf.test.ts +111 -0
- package/tests/unit/services/attendanceMerge.test.ts +90 -0
- package/tests/unit/services/attendanceRate.test.ts +38 -0
- package/tests/unit/services/attendanceStats.test.ts +54 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fee ledger service — school-admin gated monthly invoicing + payment recording.
|
|
3
|
+
* Payment gateway intentionally deferred; this is a manual ledger.
|
|
4
|
+
*/
|
|
5
|
+
import { TRPCError } from "@trpc/server";
|
|
6
|
+
import { FeeInvoiceStatus, FeePaymentMethod } from "@prisma/client";
|
|
7
|
+
import { findActiveSchoolAdminMembership } from "../models/school.js";
|
|
8
|
+
import {
|
|
9
|
+
createFeeInvoices,
|
|
10
|
+
createFeePayment,
|
|
11
|
+
createFeePlan,
|
|
12
|
+
getFeeInvoice,
|
|
13
|
+
getFeePayment,
|
|
14
|
+
getFeePlan,
|
|
15
|
+
listActiveStudentIds,
|
|
16
|
+
listFeeInvoices,
|
|
17
|
+
listFeePlans,
|
|
18
|
+
listPlanEnrollmentIds,
|
|
19
|
+
listStudentBalances,
|
|
20
|
+
setFeePlanEnrollments,
|
|
21
|
+
sumPaymentsForInvoice,
|
|
22
|
+
updateFeePlan,
|
|
23
|
+
updateInvoiceStatus,
|
|
24
|
+
} from "../models/fees.js";
|
|
25
|
+
import {
|
|
26
|
+
buildFeePdf,
|
|
27
|
+
formatNpr,
|
|
28
|
+
monthLabel,
|
|
29
|
+
} from "../lib/feePdf.js";
|
|
30
|
+
|
|
31
|
+
async function requireAdmin(schoolId: string, userId: string) {
|
|
32
|
+
const membership = await findActiveSchoolAdminMembership(schoolId, userId);
|
|
33
|
+
if (!membership) {
|
|
34
|
+
throw new TRPCError({
|
|
35
|
+
code: "FORBIDDEN",
|
|
36
|
+
message: "Only school admins can manage fees",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return membership;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function assertActiveSchoolStudents(
|
|
43
|
+
schoolId: string,
|
|
44
|
+
studentIds: string[]
|
|
45
|
+
) {
|
|
46
|
+
const activeIds = new Set(await listActiveStudentIds(schoolId));
|
|
47
|
+
const invalid = studentIds.filter((id) => !activeIds.has(id));
|
|
48
|
+
if (invalid.length > 0) {
|
|
49
|
+
throw new TRPCError({
|
|
50
|
+
code: "BAD_REQUEST",
|
|
51
|
+
message: "One or more students are not active members of this school",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function assertMonth(month: number) {
|
|
57
|
+
if (month < 1 || month > 12) {
|
|
58
|
+
throw new TRPCError({
|
|
59
|
+
code: "BAD_REQUEST",
|
|
60
|
+
message: "month must be between 1 and 12",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function invoicePaidTotal(payments: { amountNpr: number }[]): number {
|
|
66
|
+
return payments.reduce((s, p) => s + p.amountNpr, 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function deriveStatus(
|
|
70
|
+
amountNpr: number,
|
|
71
|
+
paidNpr: number,
|
|
72
|
+
current: FeeInvoiceStatus
|
|
73
|
+
): FeeInvoiceStatus {
|
|
74
|
+
if (current === "VOID") return "VOID";
|
|
75
|
+
if (paidNpr <= 0) return "UNPAID";
|
|
76
|
+
if (paidNpr >= amountNpr) return "PAID";
|
|
77
|
+
return "PARTIAL";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function withBalance<
|
|
81
|
+
T extends {
|
|
82
|
+
amountNpr: number;
|
|
83
|
+
status: FeeInvoiceStatus;
|
|
84
|
+
payments: { amountNpr: number }[];
|
|
85
|
+
},
|
|
86
|
+
>(invoice: T) {
|
|
87
|
+
const paidNpr = invoicePaidTotal(invoice.payments);
|
|
88
|
+
return {
|
|
89
|
+
...invoice,
|
|
90
|
+
paidNpr,
|
|
91
|
+
balanceNpr: Math.max(0, invoice.amountNpr - paidNpr),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function listPlans(userId: string, schoolId: string) {
|
|
96
|
+
await requireAdmin(schoolId, userId);
|
|
97
|
+
const plans = await listFeePlans(schoolId);
|
|
98
|
+
return { plans };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function getPlan(userId: string, planId: string) {
|
|
102
|
+
const plan = await getFeePlan(planId);
|
|
103
|
+
if (!plan) {
|
|
104
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Fee plan not found" });
|
|
105
|
+
}
|
|
106
|
+
await requireAdmin(plan.schoolId, userId);
|
|
107
|
+
return { plan };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function createPlan(
|
|
111
|
+
userId: string,
|
|
112
|
+
input: {
|
|
113
|
+
schoolId: string;
|
|
114
|
+
name: string;
|
|
115
|
+
description?: string | null;
|
|
116
|
+
amountNpr: number;
|
|
117
|
+
studentIds?: string[];
|
|
118
|
+
}
|
|
119
|
+
) {
|
|
120
|
+
await requireAdmin(input.schoolId, userId);
|
|
121
|
+
if (input.amountNpr <= 0) {
|
|
122
|
+
throw new TRPCError({
|
|
123
|
+
code: "BAD_REQUEST",
|
|
124
|
+
message: "amountNpr must be positive",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const plan = await createFeePlan({
|
|
128
|
+
schoolId: input.schoolId,
|
|
129
|
+
name: input.name.trim(),
|
|
130
|
+
description: input.description?.trim() || null,
|
|
131
|
+
amountNpr: input.amountNpr,
|
|
132
|
+
});
|
|
133
|
+
if (input.studentIds?.length) {
|
|
134
|
+
await assertActiveSchoolStudents(input.schoolId, input.studentIds);
|
|
135
|
+
const updated = await setFeePlanEnrollments(plan.id, input.studentIds);
|
|
136
|
+
return { plan: updated };
|
|
137
|
+
}
|
|
138
|
+
return { plan };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function updatePlan(
|
|
142
|
+
userId: string,
|
|
143
|
+
input: {
|
|
144
|
+
planId: string;
|
|
145
|
+
name?: string;
|
|
146
|
+
description?: string | null;
|
|
147
|
+
amountNpr?: number;
|
|
148
|
+
isActive?: boolean;
|
|
149
|
+
}
|
|
150
|
+
) {
|
|
151
|
+
const existing = await getFeePlan(input.planId);
|
|
152
|
+
if (!existing) {
|
|
153
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Fee plan not found" });
|
|
154
|
+
}
|
|
155
|
+
await requireAdmin(existing.schoolId, userId);
|
|
156
|
+
if (input.amountNpr != null && input.amountNpr <= 0) {
|
|
157
|
+
throw new TRPCError({
|
|
158
|
+
code: "BAD_REQUEST",
|
|
159
|
+
message: "amountNpr must be positive",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const plan = await updateFeePlan(input.planId, {
|
|
163
|
+
name: input.name?.trim(),
|
|
164
|
+
description: input.description,
|
|
165
|
+
amountNpr: input.amountNpr,
|
|
166
|
+
isActive: input.isActive,
|
|
167
|
+
});
|
|
168
|
+
return { plan };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function setPlanStudents(
|
|
172
|
+
userId: string,
|
|
173
|
+
input: { planId: string; studentIds: string[] }
|
|
174
|
+
) {
|
|
175
|
+
const existing = await getFeePlan(input.planId);
|
|
176
|
+
if (!existing) {
|
|
177
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Fee plan not found" });
|
|
178
|
+
}
|
|
179
|
+
await requireAdmin(existing.schoolId, userId);
|
|
180
|
+
await assertActiveSchoolStudents(existing.schoolId, input.studentIds);
|
|
181
|
+
const plan = await setFeePlanEnrollments(input.planId, input.studentIds);
|
|
182
|
+
return { plan };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function generateMonthlyInvoices(
|
|
186
|
+
userId: string,
|
|
187
|
+
input: {
|
|
188
|
+
schoolId: string;
|
|
189
|
+
feePlanId: string;
|
|
190
|
+
year: number;
|
|
191
|
+
month: number;
|
|
192
|
+
studentIds?: string[];
|
|
193
|
+
dueDate?: Date | null;
|
|
194
|
+
}
|
|
195
|
+
) {
|
|
196
|
+
await requireAdmin(input.schoolId, userId);
|
|
197
|
+
assertMonth(input.month);
|
|
198
|
+
|
|
199
|
+
const plan = await getFeePlan(input.feePlanId);
|
|
200
|
+
if (!plan || plan.schoolId !== input.schoolId) {
|
|
201
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Fee plan not found" });
|
|
202
|
+
}
|
|
203
|
+
if (!plan.isActive) {
|
|
204
|
+
throw new TRPCError({
|
|
205
|
+
code: "BAD_REQUEST",
|
|
206
|
+
message: "Cannot generate invoices for an inactive fee plan",
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let studentIds = input.studentIds;
|
|
211
|
+
if (studentIds?.length) {
|
|
212
|
+
await assertActiveSchoolStudents(input.schoolId, studentIds);
|
|
213
|
+
} else {
|
|
214
|
+
const enrolled = await listPlanEnrollmentIds(plan.id);
|
|
215
|
+
studentIds = enrolled.length
|
|
216
|
+
? enrolled
|
|
217
|
+
: await listActiveStudentIds(input.schoolId);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!studentIds.length) {
|
|
221
|
+
throw new TRPCError({
|
|
222
|
+
code: "BAD_REQUEST",
|
|
223
|
+
message: "No students to invoice",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const result = await createFeeInvoices(
|
|
228
|
+
studentIds.map((studentId) => ({
|
|
229
|
+
schoolId: input.schoolId,
|
|
230
|
+
feePlanId: plan.id,
|
|
231
|
+
studentId,
|
|
232
|
+
year: input.year,
|
|
233
|
+
month: input.month,
|
|
234
|
+
amountNpr: plan.amountNpr,
|
|
235
|
+
currency: plan.currency,
|
|
236
|
+
dueDate: input.dueDate ?? null,
|
|
237
|
+
}))
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const invoices = await listFeeInvoices({
|
|
241
|
+
schoolId: input.schoolId,
|
|
242
|
+
feePlanId: plan.id,
|
|
243
|
+
year: input.year,
|
|
244
|
+
month: input.month,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
createdCount: result.count,
|
|
249
|
+
invoices: invoices.map(withBalance),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function listInvoices(
|
|
254
|
+
userId: string,
|
|
255
|
+
input: {
|
|
256
|
+
schoolId: string;
|
|
257
|
+
year?: number;
|
|
258
|
+
month?: number;
|
|
259
|
+
studentId?: string;
|
|
260
|
+
feePlanId?: string;
|
|
261
|
+
status?: FeeInvoiceStatus;
|
|
262
|
+
}
|
|
263
|
+
) {
|
|
264
|
+
await requireAdmin(input.schoolId, userId);
|
|
265
|
+
if (input.month != null) assertMonth(input.month);
|
|
266
|
+
const invoices = await listFeeInvoices(input);
|
|
267
|
+
return { invoices: invoices.map(withBalance) };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function getInvoice(userId: string, invoiceId: string) {
|
|
271
|
+
const invoice = await getFeeInvoice(invoiceId);
|
|
272
|
+
if (!invoice) {
|
|
273
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Invoice not found" });
|
|
274
|
+
}
|
|
275
|
+
await requireAdmin(invoice.schoolId, userId);
|
|
276
|
+
return { invoice: withBalance(invoice) };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function recordPayment(
|
|
280
|
+
userId: string,
|
|
281
|
+
input: {
|
|
282
|
+
invoiceId: string;
|
|
283
|
+
amountNpr: number;
|
|
284
|
+
method: FeePaymentMethod;
|
|
285
|
+
reference?: string | null;
|
|
286
|
+
paidAt?: Date;
|
|
287
|
+
notes?: string | null;
|
|
288
|
+
}
|
|
289
|
+
) {
|
|
290
|
+
const invoice = await getFeeInvoice(input.invoiceId);
|
|
291
|
+
if (!invoice) {
|
|
292
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Invoice not found" });
|
|
293
|
+
}
|
|
294
|
+
await requireAdmin(invoice.schoolId, userId);
|
|
295
|
+
|
|
296
|
+
if (invoice.status === "VOID") {
|
|
297
|
+
throw new TRPCError({
|
|
298
|
+
code: "BAD_REQUEST",
|
|
299
|
+
message: "Cannot record payment on a void invoice",
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
if (input.amountNpr <= 0) {
|
|
303
|
+
throw new TRPCError({
|
|
304
|
+
code: "BAD_REQUEST",
|
|
305
|
+
message: "amountNpr must be positive",
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (
|
|
309
|
+
(input.method === "BANK" || input.method === "ESEWA_REF") &&
|
|
310
|
+
!input.reference?.trim()
|
|
311
|
+
) {
|
|
312
|
+
throw new TRPCError({
|
|
313
|
+
code: "BAD_REQUEST",
|
|
314
|
+
message: "reference is required for BANK and ESEWA_REF payments",
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const paidSoFar = invoicePaidTotal(invoice.payments);
|
|
319
|
+
const balance = Math.max(0, invoice.amountNpr - paidSoFar);
|
|
320
|
+
if (input.amountNpr > balance) {
|
|
321
|
+
throw new TRPCError({
|
|
322
|
+
code: "BAD_REQUEST",
|
|
323
|
+
message: `Payment exceeds balance (${formatNpr(balance)})`,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const payment = await createFeePayment({
|
|
328
|
+
schoolId: invoice.schoolId,
|
|
329
|
+
invoiceId: invoice.id,
|
|
330
|
+
amountNpr: input.amountNpr,
|
|
331
|
+
method: input.method,
|
|
332
|
+
reference: input.reference?.trim() || null,
|
|
333
|
+
paidAt: input.paidAt,
|
|
334
|
+
recordedById: userId,
|
|
335
|
+
notes: input.notes?.trim() || null,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const aggregate = await sumPaymentsForInvoice(invoice.id);
|
|
339
|
+
const newPaid = aggregate._sum.amountNpr ?? 0;
|
|
340
|
+
const status = deriveStatus(invoice.amountNpr, newPaid, invoice.status);
|
|
341
|
+
await updateInvoiceStatus(invoice.id, status);
|
|
342
|
+
|
|
343
|
+
const refreshed = await getFeeInvoice(invoice.id);
|
|
344
|
+
return {
|
|
345
|
+
payment,
|
|
346
|
+
invoice: refreshed ? withBalance(refreshed) : null,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function listBalances(userId: string, schoolId: string) {
|
|
351
|
+
await requireAdmin(schoolId, userId);
|
|
352
|
+
const balances = await listStudentBalances(schoolId);
|
|
353
|
+
return { balances };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export async function getInvoicePdf(userId: string, invoiceId: string) {
|
|
357
|
+
const invoice = await getFeeInvoice(invoiceId);
|
|
358
|
+
if (!invoice) {
|
|
359
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Invoice not found" });
|
|
360
|
+
}
|
|
361
|
+
await requireAdmin(invoice.schoolId, userId);
|
|
362
|
+
|
|
363
|
+
const paidNpr = invoicePaidTotal(invoice.payments);
|
|
364
|
+
const balanceNpr = Math.max(0, invoice.amountNpr - paidNpr);
|
|
365
|
+
const studentName =
|
|
366
|
+
invoice.student.profile?.displayName || invoice.student.username;
|
|
367
|
+
|
|
368
|
+
const bytes = await buildFeePdf({
|
|
369
|
+
schoolName: invoice.school.name,
|
|
370
|
+
title: "Fee Invoice",
|
|
371
|
+
subtitle: `${invoice.feePlan.name} · ${monthLabel(invoice.year, invoice.month)}`,
|
|
372
|
+
letterhead: invoice.school.letterhead
|
|
373
|
+
? {
|
|
374
|
+
path: invoice.school.letterhead.path,
|
|
375
|
+
type: invoice.school.letterhead.type,
|
|
376
|
+
}
|
|
377
|
+
: null,
|
|
378
|
+
lines: [
|
|
379
|
+
{ label: "Invoice ID", value: invoice.id.slice(0, 8).toUpperCase() },
|
|
380
|
+
{ label: "Student", value: studentName },
|
|
381
|
+
{ label: "Period", value: monthLabel(invoice.year, invoice.month) },
|
|
382
|
+
{ label: "Plan", value: invoice.feePlan.name },
|
|
383
|
+
{ label: "Amount", value: formatNpr(invoice.amountNpr) },
|
|
384
|
+
{ label: "Paid", value: formatNpr(paidNpr) },
|
|
385
|
+
{ label: "Balance", value: formatNpr(balanceNpr) },
|
|
386
|
+
{ label: "Status", value: invoice.status },
|
|
387
|
+
],
|
|
388
|
+
footerNote: "Manual ledger — not a gateway receipt. Keep for school records.",
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
fileName: `invoice-${invoice.year}-${String(invoice.month).padStart(2, "0")}-${invoice.id.slice(0, 8)}.pdf`,
|
|
393
|
+
contentType: "application/pdf",
|
|
394
|
+
base64: Buffer.from(bytes).toString("base64"),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export async function getReceiptPdf(userId: string, paymentId: string) {
|
|
399
|
+
const payment = await getFeePayment(paymentId);
|
|
400
|
+
if (!payment) {
|
|
401
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Payment not found" });
|
|
402
|
+
}
|
|
403
|
+
await requireAdmin(payment.schoolId, userId);
|
|
404
|
+
|
|
405
|
+
const invoice = payment.invoice;
|
|
406
|
+
const studentName =
|
|
407
|
+
invoice.student.profile?.displayName || invoice.student.username;
|
|
408
|
+
|
|
409
|
+
const bytes = await buildFeePdf({
|
|
410
|
+
schoolName: invoice.school.name,
|
|
411
|
+
title: "Payment Receipt",
|
|
412
|
+
subtitle: `${invoice.feePlan.name} · ${monthLabel(invoice.year, invoice.month)}`,
|
|
413
|
+
letterhead: invoice.school.letterhead
|
|
414
|
+
? {
|
|
415
|
+
path: invoice.school.letterhead.path,
|
|
416
|
+
type: invoice.school.letterhead.type,
|
|
417
|
+
}
|
|
418
|
+
: null,
|
|
419
|
+
lines: [
|
|
420
|
+
{ label: "Receipt ID", value: payment.id.slice(0, 8).toUpperCase() },
|
|
421
|
+
{ label: "Student", value: studentName },
|
|
422
|
+
{ label: "Period", value: monthLabel(invoice.year, invoice.month) },
|
|
423
|
+
{ label: "Amount paid", value: formatNpr(payment.amountNpr) },
|
|
424
|
+
{ label: "Method", value: payment.method },
|
|
425
|
+
{
|
|
426
|
+
label: "Reference",
|
|
427
|
+
value: payment.reference?.trim() || "—",
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
label: "Paid at",
|
|
431
|
+
value: new Intl.DateTimeFormat("en-CA", {
|
|
432
|
+
timeZone: "Asia/Kathmandu",
|
|
433
|
+
year: "numeric",
|
|
434
|
+
month: "2-digit",
|
|
435
|
+
day: "2-digit",
|
|
436
|
+
}).format(payment.paidAt),
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
footerNote: "Manual ledger receipt — gateway integration deferred.",
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
fileName: `receipt-${payment.id.slice(0, 8)}.pdf`,
|
|
444
|
+
contentType: "application/pdf",
|
|
445
|
+
base64: Buffer.from(bytes).toString("base64"),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C7: Fees authorization stubs for ACCOUNTANT (+ ADMIN).
|
|
3
|
+
*
|
|
4
|
+
* A2 owns full fee models; this module only provides reusable gates that
|
|
5
|
+
* fee routers can call once they land.
|
|
6
|
+
*/
|
|
7
|
+
import { SchoolMembershipRole } from "@prisma/client";
|
|
8
|
+
import { TRPCError } from "@trpc/server";
|
|
9
|
+
import { findActiveSchoolMembership } from "../models/school.js";
|
|
10
|
+
|
|
11
|
+
const FEE_WRITE_ROLES: SchoolMembershipRole[] = ["ADMIN", "ACCOUNTANT"];
|
|
12
|
+
const FEE_READ_ROLES: SchoolMembershipRole[] = [
|
|
13
|
+
"ADMIN",
|
|
14
|
+
"ACCOUNTANT",
|
|
15
|
+
"TEACHER",
|
|
16
|
+
"PARENT",
|
|
17
|
+
"STUDENT",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export async function requireSchoolRoles(
|
|
21
|
+
schoolId: string,
|
|
22
|
+
userId: string,
|
|
23
|
+
allowed: SchoolMembershipRole[],
|
|
24
|
+
message = "You do not have permission for this action"
|
|
25
|
+
) {
|
|
26
|
+
const membership = await findActiveSchoolMembership(schoolId, userId);
|
|
27
|
+
if (!membership || !allowed.includes(membership.role)) {
|
|
28
|
+
throw new TRPCError({ code: "FORBIDDEN", message });
|
|
29
|
+
}
|
|
30
|
+
return membership;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Admin or accountant may create/update fee structures and record payments. */
|
|
34
|
+
export async function requireFeeWriteAccess(schoolId: string, userId: string) {
|
|
35
|
+
return requireSchoolRoles(
|
|
36
|
+
schoolId,
|
|
37
|
+
userId,
|
|
38
|
+
FEE_WRITE_ROLES,
|
|
39
|
+
"Only school admins or accountants can manage fees"
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Broad read access stub — parents/students should be scoped to own records by A2. */
|
|
44
|
+
export async function requireFeeReadAccess(schoolId: string, userId: string) {
|
|
45
|
+
return requireSchoolRoles(
|
|
46
|
+
schoolId,
|
|
47
|
+
userId,
|
|
48
|
+
FEE_READ_ROLES,
|
|
49
|
+
"You are not a member of this school"
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function canManageFees(role: SchoolMembershipRole) {
|
|
54
|
+
return FEE_WRITE_ROLES.includes(role);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Returns capability flags so the client can show/hide fee UI. */
|
|
58
|
+
export async function getFeeCapabilities(userId: string, schoolId: string) {
|
|
59
|
+
const membership = await findActiveSchoolMembership(schoolId, userId);
|
|
60
|
+
if (!membership) {
|
|
61
|
+
throw new TRPCError({
|
|
62
|
+
code: "FORBIDDEN",
|
|
63
|
+
message: "You are not a member of this school",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
role: membership.role,
|
|
68
|
+
canManageFees: canManageFees(membership.role),
|
|
69
|
+
canViewFees: FEE_READ_ROLES.includes(membership.role),
|
|
70
|
+
feesImplemented: true as const,
|
|
71
|
+
message: "Fee features are available.",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -325,6 +325,76 @@ export async function getComputedGradebook(classId: string, userId: string) {
|
|
|
325
325
|
};
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Compute one student's final grade in a class without teacher-auth filtering.
|
|
330
|
+
* Used by school-admin transcripts (caller must enforce school admin separately).
|
|
331
|
+
*/
|
|
332
|
+
export async function computeStudentGradeInClass(
|
|
333
|
+
classId: string,
|
|
334
|
+
studentId: string
|
|
335
|
+
): Promise<{
|
|
336
|
+
result: FinalGradeResult;
|
|
337
|
+
letterGrade: string | null;
|
|
338
|
+
gradingBoundary: { id: string; name: string | null; structured: string } | null;
|
|
339
|
+
studentName: string | null;
|
|
340
|
+
} | null> {
|
|
341
|
+
const [classData, categories, columns] = await Promise.all([
|
|
342
|
+
findClassForGradebook(classId),
|
|
343
|
+
findGradeCategoriesByClassId(classId),
|
|
344
|
+
findCustomGradeColumnsByClassId(classId),
|
|
345
|
+
]);
|
|
346
|
+
if (!classData) return null;
|
|
347
|
+
|
|
348
|
+
const student = classData.students.find((s) => s.id === studentId);
|
|
349
|
+
if (!student) return null;
|
|
350
|
+
|
|
351
|
+
const policies = {
|
|
352
|
+
treatMissingAsZero: classData.treatMissingAsZero,
|
|
353
|
+
roundingMode: classData.gradeRoundingMode,
|
|
354
|
+
};
|
|
355
|
+
const categoryInputs: GradeCategoryInput[] = categories.map((category) => ({
|
|
356
|
+
id: category.id,
|
|
357
|
+
name: category.name,
|
|
358
|
+
weight: category.weight,
|
|
359
|
+
dropLowest: category.dropLowest,
|
|
360
|
+
}));
|
|
361
|
+
const boundary = classData.gradingBoundaries[0] ?? null;
|
|
362
|
+
|
|
363
|
+
const scores: ScoreInput[] = [];
|
|
364
|
+
for (const assignment of classData.assignments) {
|
|
365
|
+
const submission = assignment.submissions.find(
|
|
366
|
+
(item) => item.studentId === studentId
|
|
367
|
+
);
|
|
368
|
+
scores.push({
|
|
369
|
+
categoryId: assignment.gradeCategoryId,
|
|
370
|
+
pointsEarned: submission?.gradeReceived ?? null,
|
|
371
|
+
maxPoints: assignment.maxGrade ?? 0,
|
|
372
|
+
weight: assignment.weight,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
for (const column of columns) {
|
|
376
|
+
const entry = column.entries.find((item) => item.studentId === studentId);
|
|
377
|
+
scores.push({
|
|
378
|
+
categoryId: column.gradeCategoryId,
|
|
379
|
+
pointsEarned: entry?.points ?? null,
|
|
380
|
+
maxPoints: column.maxPoints,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const result = computeFinalGrade(policies, categoryInputs, scores);
|
|
385
|
+
const letterGrade =
|
|
386
|
+
boundary && result.percentage !== null
|
|
387
|
+
? resolveLetterGrade(boundary.structured, result.percentage)
|
|
388
|
+
: null;
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
result,
|
|
392
|
+
letterGrade,
|
|
393
|
+
gradingBoundary: boundary,
|
|
394
|
+
studentName: student.profile?.displayName || student.username,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
328
398
|
// ===== CSV export =====
|
|
329
399
|
|
|
330
400
|
function escapeCsvCell(value: string): string {
|