@studious-lms/server 1.22.0 → 1.24.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 +544 -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 +178 -0
- package/dist/models/marketplace.d.ts.map +1 -0
- package/dist/models/marketplace.js +115 -0
- package/dist/models/marketplace.js.map +1 -0
- package/dist/models/moduleItem.d.ts +304 -0
- package/dist/models/moduleItem.d.ts.map +1 -0
- package/dist/models/moduleItem.js +78 -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 +193 -0
- package/dist/models/school.d.ts.map +1 -1
- package/dist/models/school.js +80 -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 +11605 -7133
- 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 +60 -0
- package/dist/routers/moduleItem.js.map +1 -0
- package/dist/routers/parent.d.ts +169 -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 +476 -6
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +130 -4
- 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 +214 -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 +323 -10
- package/dist/services/attendance.d.ts.map +1 -1
- package/dist/services/attendance.js +385 -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 +27 -0
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +26 -3
- 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 +310 -0
- package/dist/services/fees.js.map +1 -0
- package/dist/services/feesAuthz.d.ts +49 -0
- package/dist/services/feesAuthz.d.ts.map +1 -0
- package/dist/services/feesAuthz.js +52 -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 +628 -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 +188 -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 +133 -0
- package/dist/services/parentPortal.d.ts.map +1 -0
- package/dist/services/parentPortal.js +328 -0
- package/dist/services/parentPortal.js.map +1 -0
- package/dist/services/school.d.ts +122 -3
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +179 -47
- 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 +236 -0
- package/dist/services/studentImport.js.map +1 -0
- package/dist/services/transcript.d.ts +61 -0
- package/dist/services/transcript.d.ts.map +1 -0
- package/dist/services/transcript.js +243 -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 +379 -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 +704 -73
- package/src/models/class.ts +1 -0
- package/src/models/fees.ts +349 -0
- package/src/models/marketplace.ts +158 -0
- package/src/models/moduleItem.ts +107 -0
- package/src/models/school.ts +95 -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 +89 -0
- package/src/routers/parent.ts +68 -0
- package/src/routers/school.ts +184 -1
- package/src/routers/worksheet.ts +11 -0
- package/src/services/academicCalendar.ts +299 -0
- package/src/services/assignment.ts +122 -12
- package/src/services/attendance.ts +548 -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 +25 -1
- package/src/services/fees.ts +424 -0
- package/src/services/feesAuthz.ts +77 -0
- package/src/services/gradebook.ts +70 -0
- package/src/services/idCard.ts +147 -0
- package/src/services/marketplace.ts +785 -0
- package/src/services/moduleItem.ts +274 -0
- package/src/services/notification.ts +43 -0
- package/src/services/parentPortal.ts +380 -0
- package/src/services/school.ts +243 -45
- package/src/services/studentImport.ts +273 -0
- package/src/services/transcript.ts +313 -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,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for merging teacher attendance updates with excusal locks.
|
|
3
|
+
*/
|
|
4
|
+
import type { AttendanceStatus } from "@prisma/client";
|
|
5
|
+
|
|
6
|
+
export type StudentRef = { id: string; username: string };
|
|
7
|
+
|
|
8
|
+
export type AttendanceLock = {
|
|
9
|
+
studentId: string;
|
|
10
|
+
excusalId: string;
|
|
11
|
+
reason?: string | null;
|
|
12
|
+
note?: string | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type MergedAttendanceUpdate = {
|
|
16
|
+
presentIds: string[];
|
|
17
|
+
lateIds: string[];
|
|
18
|
+
absentIds: string[];
|
|
19
|
+
excusedIds: string[];
|
|
20
|
+
records: Array<{
|
|
21
|
+
studentId: string;
|
|
22
|
+
status: AttendanceStatus;
|
|
23
|
+
note?: string | null;
|
|
24
|
+
excusedByExcusalId?: string | null;
|
|
25
|
+
updatedById?: string | null;
|
|
26
|
+
}>;
|
|
27
|
+
/** Students whose status was forced to EXCUSED by an active excusal. */
|
|
28
|
+
lockedStudentIds: string[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build present/late/absent/excused lists + per-student records.
|
|
33
|
+
* Active excusal locks always win (status EXCUSED + excusedByExcusalId set).
|
|
34
|
+
*/
|
|
35
|
+
export function mergeAttendanceUpdateWithLocks(input: {
|
|
36
|
+
present: StudentRef[];
|
|
37
|
+
late: StudentRef[];
|
|
38
|
+
absent: StudentRef[];
|
|
39
|
+
excused?: StudentRef[];
|
|
40
|
+
locks: AttendanceLock[];
|
|
41
|
+
updatedById?: string | null;
|
|
42
|
+
/** Preserve notes for locked students when available. */
|
|
43
|
+
existingNotesByStudentId?: Map<string, string | null | undefined>;
|
|
44
|
+
}): MergedAttendanceUpdate {
|
|
45
|
+
const lockByStudent = new Map(input.locks.map((lock) => [lock.studentId, lock]));
|
|
46
|
+
const lockedStudentIds = [...lockByStudent.keys()];
|
|
47
|
+
|
|
48
|
+
const stripLocked = (students: StudentRef[]) =>
|
|
49
|
+
students.filter((s) => !lockByStudent.has(s.id));
|
|
50
|
+
|
|
51
|
+
const presentIds = stripLocked(input.present).map((s) => s.id);
|
|
52
|
+
const lateIds = stripLocked(input.late).map((s) => s.id);
|
|
53
|
+
const absentIds = stripLocked(input.absent).map((s) => s.id);
|
|
54
|
+
const manualExcusedIds = stripLocked(input.excused ?? []).map((s) => s.id);
|
|
55
|
+
const excusedIds = [...new Set([...manualExcusedIds, ...lockedStudentIds])];
|
|
56
|
+
|
|
57
|
+
const records: MergedAttendanceUpdate["records"] = [];
|
|
58
|
+
|
|
59
|
+
for (const studentId of presentIds) {
|
|
60
|
+
records.push({
|
|
61
|
+
studentId,
|
|
62
|
+
status: "PRESENT",
|
|
63
|
+
updatedById: input.updatedById,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
for (const studentId of lateIds) {
|
|
67
|
+
records.push({
|
|
68
|
+
studentId,
|
|
69
|
+
status: "LATE",
|
|
70
|
+
updatedById: input.updatedById,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
for (const studentId of absentIds) {
|
|
74
|
+
records.push({
|
|
75
|
+
studentId,
|
|
76
|
+
status: "ABSENT",
|
|
77
|
+
updatedById: input.updatedById,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
for (const studentId of manualExcusedIds) {
|
|
81
|
+
records.push({
|
|
82
|
+
studentId,
|
|
83
|
+
status: "EXCUSED",
|
|
84
|
+
excusedByExcusalId: null,
|
|
85
|
+
updatedById: input.updatedById,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
for (const lock of input.locks) {
|
|
89
|
+
records.push({
|
|
90
|
+
studentId: lock.studentId,
|
|
91
|
+
status: "EXCUSED",
|
|
92
|
+
note:
|
|
93
|
+
input.existingNotesByStudentId?.get(lock.studentId) ?? lock.note ?? null,
|
|
94
|
+
excusedByExcusalId: lock.excusalId,
|
|
95
|
+
updatedById: input.updatedById,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
presentIds,
|
|
101
|
+
lateIds,
|
|
102
|
+
absentIds,
|
|
103
|
+
excusedIds,
|
|
104
|
+
records,
|
|
105
|
+
lockedStudentIds,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Map active excusals (with student join rows) to per-student locks. */
|
|
110
|
+
export function buildExcusalLocks(
|
|
111
|
+
excusals: Array<{
|
|
112
|
+
id: string;
|
|
113
|
+
reason?: string | null;
|
|
114
|
+
note?: string | null;
|
|
115
|
+
students: Array<{ studentId: string }>;
|
|
116
|
+
}>,
|
|
117
|
+
studentIds?: Iterable<string>
|
|
118
|
+
): AttendanceLock[] {
|
|
119
|
+
const allow =
|
|
120
|
+
studentIds === undefined ? null : new Set(studentIds);
|
|
121
|
+
const locks: AttendanceLock[] = [];
|
|
122
|
+
const seen = new Set<string>();
|
|
123
|
+
|
|
124
|
+
for (const excusal of excusals) {
|
|
125
|
+
for (const row of excusal.students) {
|
|
126
|
+
if (allow && !allow.has(row.studentId)) continue;
|
|
127
|
+
if (seen.has(row.studentId)) continue;
|
|
128
|
+
seen.add(row.studentId);
|
|
129
|
+
locks.push({
|
|
130
|
+
studentId: row.studentId,
|
|
131
|
+
excusalId: excusal.id,
|
|
132
|
+
reason: excusal.reason,
|
|
133
|
+
note: excusal.note,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return locks;
|
|
139
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attendance rate helpers for transcripts / progress reports.
|
|
3
|
+
*
|
|
4
|
+
* Reads the current Attendance M2M shape only (present / late / absent).
|
|
5
|
+
* Do NOT write Attendance schema here — A1 may introduce EXCUSED /
|
|
6
|
+
* AttendanceRecord; keep rate logic in this one place so A1 can extend it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type AttendanceStatus = "PRESENT" | "LATE" | "ABSENT";
|
|
10
|
+
|
|
11
|
+
export interface AttendanceRateSummary {
|
|
12
|
+
present: number;
|
|
13
|
+
late: number;
|
|
14
|
+
absent: number;
|
|
15
|
+
total: number;
|
|
16
|
+
/** (present + late) / total * 100, or null when total is 0. */
|
|
17
|
+
percent: number | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Computes attendance counts and rate from shaped status records.
|
|
22
|
+
* Late counts toward attendance (present for rate purposes).
|
|
23
|
+
*/
|
|
24
|
+
export function computeAttendanceRate(
|
|
25
|
+
records: { status: AttendanceStatus }[]
|
|
26
|
+
): AttendanceRateSummary {
|
|
27
|
+
let present = 0;
|
|
28
|
+
let late = 0;
|
|
29
|
+
let absent = 0;
|
|
30
|
+
|
|
31
|
+
for (const record of records) {
|
|
32
|
+
if (record.status === "PRESENT") present += 1;
|
|
33
|
+
else if (record.status === "LATE") late += 1;
|
|
34
|
+
else if (record.status === "ABSENT") absent += 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const total = present + late + absent;
|
|
38
|
+
const percent = total === 0 ? null : ((present + late) / total) * 100;
|
|
39
|
+
|
|
40
|
+
return { present, late, absent, total, percent };
|
|
41
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure attendance percentage helpers.
|
|
3
|
+
* EXCUSED days are excluded from both numerator and denominator.
|
|
4
|
+
*/
|
|
5
|
+
import type { AttendanceStatus } from "@prisma/client";
|
|
6
|
+
|
|
7
|
+
export type AttendanceStatusCount = {
|
|
8
|
+
present: number;
|
|
9
|
+
late: number;
|
|
10
|
+
absent: number;
|
|
11
|
+
excused: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function emptyAttendanceCounts(): AttendanceStatusCount {
|
|
15
|
+
return { present: 0, late: 0, absent: 0, excused: 0 };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function tallyAttendanceStatuses(
|
|
19
|
+
statuses: Iterable<AttendanceStatus | string>
|
|
20
|
+
): AttendanceStatusCount {
|
|
21
|
+
const counts = emptyAttendanceCounts();
|
|
22
|
+
for (const status of statuses) {
|
|
23
|
+
switch (status) {
|
|
24
|
+
case "PRESENT":
|
|
25
|
+
counts.present += 1;
|
|
26
|
+
break;
|
|
27
|
+
case "LATE":
|
|
28
|
+
counts.late += 1;
|
|
29
|
+
break;
|
|
30
|
+
case "ABSENT":
|
|
31
|
+
counts.absent += 1;
|
|
32
|
+
break;
|
|
33
|
+
case "EXCUSED":
|
|
34
|
+
counts.excused += 1;
|
|
35
|
+
break;
|
|
36
|
+
default:
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return counts;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Attendance rate as a percentage.
|
|
45
|
+
* Formula: (present + late) / (present + late + absent) * 100
|
|
46
|
+
* Excused records are ignored entirely.
|
|
47
|
+
* Returns null when there are no countable (non-excused) records.
|
|
48
|
+
*/
|
|
49
|
+
export function computeAttendancePercentage(
|
|
50
|
+
counts: Pick<AttendanceStatusCount, "present" | "late" | "absent">
|
|
51
|
+
): number | null {
|
|
52
|
+
const countable = counts.present + counts.late + counts.absent;
|
|
53
|
+
if (countable === 0) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return ((counts.present + counts.late) / countable) * 100;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function computeAttendancePercentageFromStatuses(
|
|
60
|
+
statuses: Iterable<AttendanceStatus | string>
|
|
61
|
+
): number | null {
|
|
62
|
+
return computeAttendancePercentage(tallyAttendanceStatuses(statuses));
|
|
63
|
+
}
|
package/src/services/class.ts
CHANGED
|
@@ -196,6 +196,7 @@ export async function updateClass(
|
|
|
196
196
|
discussionsEnabled?: boolean;
|
|
197
197
|
studentsCanCreateDiscussions?: boolean;
|
|
198
198
|
studentsCanReply?: boolean;
|
|
199
|
+
sidebarFeatures?: Record<string, boolean> | null;
|
|
199
200
|
}
|
|
200
201
|
) {
|
|
201
202
|
if (data.departmentId !== undefined) {
|
|
@@ -226,7 +227,29 @@ export async function updateClass(
|
|
|
226
227
|
|
|
227
228
|
const updatedClass = await prisma.class.update({
|
|
228
229
|
where: { id: classId },
|
|
229
|
-
|
|
230
|
+
// Prisma Exact<> rejects mixed relation/unchecked shapes; pass unchecked scalars.
|
|
231
|
+
data: {
|
|
232
|
+
...(data.name !== undefined && { name: data.name }),
|
|
233
|
+
...(data.section !== undefined && { section: data.section }),
|
|
234
|
+
...(data.subject !== undefined && { subject: data.subject }),
|
|
235
|
+
...(data.color !== undefined && { color: data.color }),
|
|
236
|
+
...(data.symbol !== undefined && { symbol: data.symbol }),
|
|
237
|
+
...(data.departmentId !== undefined && {
|
|
238
|
+
departmentId: data.departmentId,
|
|
239
|
+
}),
|
|
240
|
+
...(data.discussionsEnabled !== undefined && {
|
|
241
|
+
discussionsEnabled: data.discussionsEnabled,
|
|
242
|
+
}),
|
|
243
|
+
...(data.studentsCanCreateDiscussions !== undefined && {
|
|
244
|
+
studentsCanCreateDiscussions: data.studentsCanCreateDiscussions,
|
|
245
|
+
}),
|
|
246
|
+
...(data.studentsCanReply !== undefined && {
|
|
247
|
+
studentsCanReply: data.studentsCanReply,
|
|
248
|
+
}),
|
|
249
|
+
...(data.sidebarFeatures !== undefined && {
|
|
250
|
+
sidebarFeatures: data.sidebarFeatures,
|
|
251
|
+
}),
|
|
252
|
+
} as Parameters<typeof prisma.class.update>[0]["data"],
|
|
230
253
|
select: {
|
|
231
254
|
id: true,
|
|
232
255
|
name: true,
|
|
@@ -238,6 +261,7 @@ export async function updateClass(
|
|
|
238
261
|
discussionsEnabled: true,
|
|
239
262
|
studentsCanCreateDiscussions: true,
|
|
240
263
|
studentsCanReply: true,
|
|
264
|
+
sidebarFeatures: true,
|
|
241
265
|
},
|
|
242
266
|
});
|
|
243
267
|
return { updatedClass };
|
|
@@ -0,0 +1,424 @@
|
|
|
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
|
+
function assertMonth(month: number) {
|
|
43
|
+
if (month < 1 || month > 12) {
|
|
44
|
+
throw new TRPCError({
|
|
45
|
+
code: "BAD_REQUEST",
|
|
46
|
+
message: "month must be between 1 and 12",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function invoicePaidTotal(payments: { amountNpr: number }[]): number {
|
|
52
|
+
return payments.reduce((s, p) => s + p.amountNpr, 0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function deriveStatus(
|
|
56
|
+
amountNpr: number,
|
|
57
|
+
paidNpr: number,
|
|
58
|
+
current: FeeInvoiceStatus
|
|
59
|
+
): FeeInvoiceStatus {
|
|
60
|
+
if (current === "VOID") return "VOID";
|
|
61
|
+
if (paidNpr <= 0) return "UNPAID";
|
|
62
|
+
if (paidNpr >= amountNpr) return "PAID";
|
|
63
|
+
return "PARTIAL";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function withBalance<
|
|
67
|
+
T extends {
|
|
68
|
+
amountNpr: number;
|
|
69
|
+
status: FeeInvoiceStatus;
|
|
70
|
+
payments: { amountNpr: number }[];
|
|
71
|
+
},
|
|
72
|
+
>(invoice: T) {
|
|
73
|
+
const paidNpr = invoicePaidTotal(invoice.payments);
|
|
74
|
+
return {
|
|
75
|
+
...invoice,
|
|
76
|
+
paidNpr,
|
|
77
|
+
balanceNpr: Math.max(0, invoice.amountNpr - paidNpr),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function listPlans(userId: string, schoolId: string) {
|
|
82
|
+
await requireAdmin(schoolId, userId);
|
|
83
|
+
const plans = await listFeePlans(schoolId);
|
|
84
|
+
return { plans };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function getPlan(userId: string, planId: string) {
|
|
88
|
+
const plan = await getFeePlan(planId);
|
|
89
|
+
if (!plan) {
|
|
90
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Fee plan not found" });
|
|
91
|
+
}
|
|
92
|
+
await requireAdmin(plan.schoolId, userId);
|
|
93
|
+
return { plan };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function createPlan(
|
|
97
|
+
userId: string,
|
|
98
|
+
input: {
|
|
99
|
+
schoolId: string;
|
|
100
|
+
name: string;
|
|
101
|
+
description?: string | null;
|
|
102
|
+
amountNpr: number;
|
|
103
|
+
studentIds?: string[];
|
|
104
|
+
}
|
|
105
|
+
) {
|
|
106
|
+
await requireAdmin(input.schoolId, userId);
|
|
107
|
+
if (input.amountNpr <= 0) {
|
|
108
|
+
throw new TRPCError({
|
|
109
|
+
code: "BAD_REQUEST",
|
|
110
|
+
message: "amountNpr must be positive",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const plan = await createFeePlan({
|
|
114
|
+
schoolId: input.schoolId,
|
|
115
|
+
name: input.name.trim(),
|
|
116
|
+
description: input.description?.trim() || null,
|
|
117
|
+
amountNpr: input.amountNpr,
|
|
118
|
+
});
|
|
119
|
+
if (input.studentIds?.length) {
|
|
120
|
+
const updated = await setFeePlanEnrollments(plan.id, input.studentIds);
|
|
121
|
+
return { plan: updated };
|
|
122
|
+
}
|
|
123
|
+
return { plan };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function updatePlan(
|
|
127
|
+
userId: string,
|
|
128
|
+
input: {
|
|
129
|
+
planId: string;
|
|
130
|
+
name?: string;
|
|
131
|
+
description?: string | null;
|
|
132
|
+
amountNpr?: number;
|
|
133
|
+
isActive?: boolean;
|
|
134
|
+
}
|
|
135
|
+
) {
|
|
136
|
+
const existing = await getFeePlan(input.planId);
|
|
137
|
+
if (!existing) {
|
|
138
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Fee plan not found" });
|
|
139
|
+
}
|
|
140
|
+
await requireAdmin(existing.schoolId, userId);
|
|
141
|
+
if (input.amountNpr != null && input.amountNpr <= 0) {
|
|
142
|
+
throw new TRPCError({
|
|
143
|
+
code: "BAD_REQUEST",
|
|
144
|
+
message: "amountNpr must be positive",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const plan = await updateFeePlan(input.planId, {
|
|
148
|
+
name: input.name?.trim(),
|
|
149
|
+
description: input.description,
|
|
150
|
+
amountNpr: input.amountNpr,
|
|
151
|
+
isActive: input.isActive,
|
|
152
|
+
});
|
|
153
|
+
return { plan };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function setPlanStudents(
|
|
157
|
+
userId: string,
|
|
158
|
+
input: { planId: string; studentIds: string[] }
|
|
159
|
+
) {
|
|
160
|
+
const existing = await getFeePlan(input.planId);
|
|
161
|
+
if (!existing) {
|
|
162
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Fee plan not found" });
|
|
163
|
+
}
|
|
164
|
+
await requireAdmin(existing.schoolId, userId);
|
|
165
|
+
const plan = await setFeePlanEnrollments(input.planId, input.studentIds);
|
|
166
|
+
return { plan };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function generateMonthlyInvoices(
|
|
170
|
+
userId: string,
|
|
171
|
+
input: {
|
|
172
|
+
schoolId: string;
|
|
173
|
+
feePlanId: string;
|
|
174
|
+
year: number;
|
|
175
|
+
month: number;
|
|
176
|
+
studentIds?: string[];
|
|
177
|
+
dueDate?: Date | null;
|
|
178
|
+
}
|
|
179
|
+
) {
|
|
180
|
+
await requireAdmin(input.schoolId, userId);
|
|
181
|
+
assertMonth(input.month);
|
|
182
|
+
|
|
183
|
+
const plan = await getFeePlan(input.feePlanId);
|
|
184
|
+
if (!plan || plan.schoolId !== input.schoolId) {
|
|
185
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Fee plan not found" });
|
|
186
|
+
}
|
|
187
|
+
if (!plan.isActive) {
|
|
188
|
+
throw new TRPCError({
|
|
189
|
+
code: "BAD_REQUEST",
|
|
190
|
+
message: "Cannot generate invoices for an inactive fee plan",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let studentIds = input.studentIds;
|
|
195
|
+
if (!studentIds?.length) {
|
|
196
|
+
const enrolled = await listPlanEnrollmentIds(plan.id);
|
|
197
|
+
studentIds = enrolled.length
|
|
198
|
+
? enrolled
|
|
199
|
+
: await listActiveStudentIds(input.schoolId);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!studentIds.length) {
|
|
203
|
+
throw new TRPCError({
|
|
204
|
+
code: "BAD_REQUEST",
|
|
205
|
+
message: "No students to invoice",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const result = await createFeeInvoices(
|
|
210
|
+
studentIds.map((studentId) => ({
|
|
211
|
+
schoolId: input.schoolId,
|
|
212
|
+
feePlanId: plan.id,
|
|
213
|
+
studentId,
|
|
214
|
+
year: input.year,
|
|
215
|
+
month: input.month,
|
|
216
|
+
amountNpr: plan.amountNpr,
|
|
217
|
+
currency: plan.currency,
|
|
218
|
+
dueDate: input.dueDate ?? null,
|
|
219
|
+
}))
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const invoices = await listFeeInvoices({
|
|
223
|
+
schoolId: input.schoolId,
|
|
224
|
+
feePlanId: plan.id,
|
|
225
|
+
year: input.year,
|
|
226
|
+
month: input.month,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
createdCount: result.count,
|
|
231
|
+
invoices: invoices.map(withBalance),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function listInvoices(
|
|
236
|
+
userId: string,
|
|
237
|
+
input: {
|
|
238
|
+
schoolId: string;
|
|
239
|
+
year?: number;
|
|
240
|
+
month?: number;
|
|
241
|
+
studentId?: string;
|
|
242
|
+
feePlanId?: string;
|
|
243
|
+
status?: FeeInvoiceStatus;
|
|
244
|
+
}
|
|
245
|
+
) {
|
|
246
|
+
await requireAdmin(input.schoolId, userId);
|
|
247
|
+
if (input.month != null) assertMonth(input.month);
|
|
248
|
+
const invoices = await listFeeInvoices(input);
|
|
249
|
+
return { invoices: invoices.map(withBalance) };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function getInvoice(userId: string, invoiceId: string) {
|
|
253
|
+
const invoice = await getFeeInvoice(invoiceId);
|
|
254
|
+
if (!invoice) {
|
|
255
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Invoice not found" });
|
|
256
|
+
}
|
|
257
|
+
await requireAdmin(invoice.schoolId, userId);
|
|
258
|
+
return { invoice: withBalance(invoice) };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function recordPayment(
|
|
262
|
+
userId: string,
|
|
263
|
+
input: {
|
|
264
|
+
invoiceId: string;
|
|
265
|
+
amountNpr: number;
|
|
266
|
+
method: FeePaymentMethod;
|
|
267
|
+
reference?: string | null;
|
|
268
|
+
paidAt?: Date;
|
|
269
|
+
notes?: string | null;
|
|
270
|
+
}
|
|
271
|
+
) {
|
|
272
|
+
const invoice = await getFeeInvoice(input.invoiceId);
|
|
273
|
+
if (!invoice) {
|
|
274
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Invoice not found" });
|
|
275
|
+
}
|
|
276
|
+
await requireAdmin(invoice.schoolId, userId);
|
|
277
|
+
|
|
278
|
+
if (invoice.status === "VOID") {
|
|
279
|
+
throw new TRPCError({
|
|
280
|
+
code: "BAD_REQUEST",
|
|
281
|
+
message: "Cannot record payment on a void invoice",
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (input.amountNpr <= 0) {
|
|
285
|
+
throw new TRPCError({
|
|
286
|
+
code: "BAD_REQUEST",
|
|
287
|
+
message: "amountNpr must be positive",
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
if (
|
|
291
|
+
(input.method === "BANK" || input.method === "ESEWA_REF") &&
|
|
292
|
+
!input.reference?.trim()
|
|
293
|
+
) {
|
|
294
|
+
throw new TRPCError({
|
|
295
|
+
code: "BAD_REQUEST",
|
|
296
|
+
message: "reference is required for BANK and ESEWA_REF payments",
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const paidSoFar = invoicePaidTotal(invoice.payments);
|
|
301
|
+
const balance = Math.max(0, invoice.amountNpr - paidSoFar);
|
|
302
|
+
if (input.amountNpr > balance) {
|
|
303
|
+
throw new TRPCError({
|
|
304
|
+
code: "BAD_REQUEST",
|
|
305
|
+
message: `Payment exceeds balance (${formatNpr(balance)})`,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const payment = await createFeePayment({
|
|
310
|
+
schoolId: invoice.schoolId,
|
|
311
|
+
invoiceId: invoice.id,
|
|
312
|
+
amountNpr: input.amountNpr,
|
|
313
|
+
method: input.method,
|
|
314
|
+
reference: input.reference?.trim() || null,
|
|
315
|
+
paidAt: input.paidAt,
|
|
316
|
+
recordedById: userId,
|
|
317
|
+
notes: input.notes?.trim() || null,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const aggregate = await sumPaymentsForInvoice(invoice.id);
|
|
321
|
+
const newPaid = aggregate._sum.amountNpr ?? 0;
|
|
322
|
+
const status = deriveStatus(invoice.amountNpr, newPaid, invoice.status);
|
|
323
|
+
await updateInvoiceStatus(invoice.id, status);
|
|
324
|
+
|
|
325
|
+
const refreshed = await getFeeInvoice(invoice.id);
|
|
326
|
+
return {
|
|
327
|
+
payment,
|
|
328
|
+
invoice: refreshed ? withBalance(refreshed) : null,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function listBalances(userId: string, schoolId: string) {
|
|
333
|
+
await requireAdmin(schoolId, userId);
|
|
334
|
+
const balances = await listStudentBalances(schoolId);
|
|
335
|
+
return { balances };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function getInvoicePdf(userId: string, invoiceId: string) {
|
|
339
|
+
const invoice = await getFeeInvoice(invoiceId);
|
|
340
|
+
if (!invoice) {
|
|
341
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Invoice not found" });
|
|
342
|
+
}
|
|
343
|
+
await requireAdmin(invoice.schoolId, userId);
|
|
344
|
+
|
|
345
|
+
const paidNpr = invoicePaidTotal(invoice.payments);
|
|
346
|
+
const balanceNpr = Math.max(0, invoice.amountNpr - paidNpr);
|
|
347
|
+
const studentName =
|
|
348
|
+
invoice.student.profile?.displayName || invoice.student.username;
|
|
349
|
+
|
|
350
|
+
const bytes = await buildFeePdf({
|
|
351
|
+
schoolName: invoice.school.name,
|
|
352
|
+
title: "Fee Invoice",
|
|
353
|
+
subtitle: `${invoice.feePlan.name} · ${monthLabel(invoice.year, invoice.month)}`,
|
|
354
|
+
letterhead: invoice.school.letterhead
|
|
355
|
+
? {
|
|
356
|
+
path: invoice.school.letterhead.path,
|
|
357
|
+
type: invoice.school.letterhead.type,
|
|
358
|
+
}
|
|
359
|
+
: null,
|
|
360
|
+
lines: [
|
|
361
|
+
{ label: "Invoice ID", value: invoice.id.slice(0, 8).toUpperCase() },
|
|
362
|
+
{ label: "Student", value: studentName },
|
|
363
|
+
{ label: "Period", value: monthLabel(invoice.year, invoice.month) },
|
|
364
|
+
{ label: "Plan", value: invoice.feePlan.name },
|
|
365
|
+
{ label: "Amount", value: formatNpr(invoice.amountNpr) },
|
|
366
|
+
{ label: "Paid", value: formatNpr(paidNpr) },
|
|
367
|
+
{ label: "Balance", value: formatNpr(balanceNpr) },
|
|
368
|
+
{ label: "Status", value: invoice.status },
|
|
369
|
+
],
|
|
370
|
+
footerNote: "Manual ledger — not a gateway receipt. Keep for school records.",
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
fileName: `invoice-${invoice.year}-${String(invoice.month).padStart(2, "0")}-${invoice.id.slice(0, 8)}.pdf`,
|
|
375
|
+
contentType: "application/pdf",
|
|
376
|
+
base64: Buffer.from(bytes).toString("base64"),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export async function getReceiptPdf(userId: string, paymentId: string) {
|
|
381
|
+
const payment = await getFeePayment(paymentId);
|
|
382
|
+
if (!payment) {
|
|
383
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Payment not found" });
|
|
384
|
+
}
|
|
385
|
+
await requireAdmin(payment.schoolId, userId);
|
|
386
|
+
|
|
387
|
+
const invoice = payment.invoice;
|
|
388
|
+
const studentName =
|
|
389
|
+
invoice.student.profile?.displayName || invoice.student.username;
|
|
390
|
+
|
|
391
|
+
const bytes = await buildFeePdf({
|
|
392
|
+
schoolName: invoice.school.name,
|
|
393
|
+
title: "Payment Receipt",
|
|
394
|
+
subtitle: `${invoice.feePlan.name} · ${monthLabel(invoice.year, invoice.month)}`,
|
|
395
|
+
letterhead: invoice.school.letterhead
|
|
396
|
+
? {
|
|
397
|
+
path: invoice.school.letterhead.path,
|
|
398
|
+
type: invoice.school.letterhead.type,
|
|
399
|
+
}
|
|
400
|
+
: null,
|
|
401
|
+
lines: [
|
|
402
|
+
{ label: "Receipt ID", value: payment.id.slice(0, 8).toUpperCase() },
|
|
403
|
+
{ label: "Student", value: studentName },
|
|
404
|
+
{ label: "Period", value: monthLabel(invoice.year, invoice.month) },
|
|
405
|
+
{ label: "Amount paid", value: formatNpr(payment.amountNpr) },
|
|
406
|
+
{ label: "Method", value: payment.method },
|
|
407
|
+
{
|
|
408
|
+
label: "Reference",
|
|
409
|
+
value: payment.reference?.trim() || "—",
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
label: "Paid at",
|
|
413
|
+
value: payment.paidAt.toISOString().slice(0, 10),
|
|
414
|
+
},
|
|
415
|
+
],
|
|
416
|
+
footerNote: "Manual ledger receipt — gateway integration deferred.",
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
fileName: `receipt-${payment.id.slice(0, 8)}.pdf`,
|
|
421
|
+
contentType: "application/pdf",
|
|
422
|
+
base64: Buffer.from(bytes).toString("base64"),
|
|
423
|
+
};
|
|
424
|
+
}
|