# School Year Closure and New Year Rollover Implementation Plan ## 1. Objective Build a controlled school-year closure feature that allows an authorized administrator to close the current school year, make it read-only, create a new active school year, promote students, and transfer unpaid balances to the new school year based on `parent_id`. The old school year must remain viewable through a school-year selector, but it must not allow editing, deleting, importing, payment posting, attendance changes, grade changes, enrollment changes, or invoice modifications. Unpaid balances must be transferred at the parent level, not the student level, because one parent may be responsible for multiple students. --- ## 2. Core Business Rules 1. Only one school year can be active at a time. 2. Closed school years are read-only. 3. Every academic and financial record must be linked to a `school_year_id`. 4. Historical records must not be overwritten during year closure. 5. Student promotion must create new enrollments for the new year. 6. Old enrollments must remain unchanged. 7. Unpaid balances must be calculated by `parent_id`. 8. Transferred balances must be traceable to the original year and original invoices. 9. The closure process must run inside a database transaction. 10. If one closure step fails, the entire operation must roll back. 11. Only authorized users can close, reopen, or archive school years. 12. Duplicate balance transfers must be prevented. 13. Payments after closure should be posted in the new active year against the transferred balance invoice. --- ## 3. Required Data Model ### 3.1 `school_years` ```sql CREATE TABLE school_years ( id BIGINT PRIMARY KEY, name VARCHAR(50) NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, status VARCHAR(20) NOT NULL, is_current BOOLEAN DEFAULT FALSE, closed_at TIMESTAMP NULL, closed_by BIGINT NULL, created_at TIMESTAMP, updated_at TIMESTAMP ); ``` Recommended statuses: ```text draft = created but not active yet active = current editable school year closed = read-only historical school year archived = old read-only year, hidden from default views if needed ``` There must be a database or application-level rule preventing more than one active/current school year. --- ### 3.2 `student_enrollments` Students should not store their current grade/class as the only source of truth. Enrollment must be year-based. ```sql CREATE TABLE student_enrollments ( id BIGINT PRIMARY KEY, student_id BIGINT NOT NULL, school_year_id BIGINT NOT NULL, grade_id BIGINT NOT NULL, class_id BIGINT NULL, status VARCHAR(30) NOT NULL, created_at TIMESTAMP, updated_at TIMESTAMP ); ``` Recommended enrollment statuses: ```text enrolled promoted repeated graduated transferred withdrawn ``` This allows the same student to have a clean academic history across multiple school years. --- ### 3.3 `parent_accounts` Each parent should have one account per school year. ```sql CREATE TABLE parent_accounts ( id BIGINT PRIMARY KEY, parent_id BIGINT NOT NULL, school_year_id BIGINT NOT NULL, opening_balance DECIMAL(12,2) DEFAULT 0, current_balance DECIMAL(12,2) DEFAULT 0, created_at TIMESTAMP, updated_at TIMESTAMP ); ``` Recommended unique constraint: ```sql ALTER TABLE parent_accounts ADD CONSTRAINT unique_parent_account_per_year UNIQUE (parent_id, school_year_id); ``` --- ### 3.4 `invoices` Invoices must belong to a school year and a parent. `student_id` should be nullable for parent-level balances. ```sql CREATE TABLE invoices ( id BIGINT PRIMARY KEY, parent_id BIGINT NOT NULL, student_id BIGINT NULL, school_year_id BIGINT NOT NULL, amount DECIMAL(12,2) NOT NULL, paid_amount DECIMAL(12,2) DEFAULT 0, balance_due DECIMAL(12,2) NOT NULL, status VARCHAR(30) NOT NULL, invoice_type VARCHAR(50), description TEXT NULL, balance_transfer_id BIGINT NULL, created_at TIMESTAMP, updated_at TIMESTAMP ); ``` Recommended invoice statuses: ```text unpaid partially_paid paid cancelled void ``` Recommended invoice types: ```text tuition transport books registration old_balance adjustment ``` --- ### 3.5 `parent_balance_transfers` This table records unpaid balances transferred from one school year to another. ```sql CREATE TABLE parent_balance_transfers ( id BIGINT PRIMARY KEY, parent_id BIGINT NOT NULL, from_school_year_id BIGINT NOT NULL, to_school_year_id BIGINT NOT NULL, amount DECIMAL(12,2) NOT NULL, status VARCHAR(30) NOT NULL, source_summary JSON NULL, created_by BIGINT NOT NULL, created_at TIMESTAMP ); ``` Recommended statuses: ```text pending transferred reversed ``` Prevent duplicate transfers: ```sql ALTER TABLE parent_balance_transfers ADD CONSTRAINT unique_parent_year_balance_transfer UNIQUE (parent_id, from_school_year_id, to_school_year_id); ``` Example `source_summary`: ```json { "unpaid_invoice_ids": [101, 102, 140], "total_due": 1200.00, "total_paid": 800.00, "remaining_balance": 400.00 } ``` --- ### 3.6 `audit_logs` ```sql CREATE TABLE audit_logs ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, action VARCHAR(100) NOT NULL, table_name VARCHAR(100) NULL, record_id BIGINT NULL, old_value JSON NULL, new_value JSON NULL, metadata JSON NULL, created_at TIMESTAMP ); ``` Audit logs must record year closure, balance transfers, reopening, student promotion, and old-balance invoice creation. --- ## 4. Close-Year Workflow The administrator should close a school year through a guided wizard. ### Step 1: Select Current Year The system identifies the active school year. Example: ```text Current school year: 2024-2025 Status: Active ``` Only an active school year can be closed. --- ### Step 2: Enter New School Year Details Admin enters: ```text New school year name Start date End date ``` Example: ```text Name: 2025-2026 Start date: 2025-09-01 End date: 2026-06-30 ``` Validation rules: 1. Name must be unique. 2. Start date must be before end date. 3. New year must not overlap an existing year. 4. There must not already be another active year. --- ### Step 3: Student Promotion Preview The system previews what will happen to students. Possible actions: ```text Promote to next grade Repeat same grade Graduate Transfer out Withdraw Hold for manual review ``` The preview should show: ```text Student name Current grade Current class Promotion action Target grade Target class Warnings ``` The system must block closure if students are missing required promotion decisions. --- ### Step 4: Financial Preview The system calculates unpaid balances grouped by `parent_id`. Preview columns: ```text Parent ID Parent name Students under parent Unpaid invoice count Old unpaid balance Credit balance, if any Amount to transfer Transfer status ``` Example: ```text Parent ID: P-102 Parent: Mohamed Ali Students: Sara Ali, Adam Ali Old balance: 450.00 Transfer to new year: Yes ``` This preview is critical. Closing a school year without a financial preview is how systems create accounting disasters with a user interface. --- ### Step 5: Final Confirmation Require strong confirmation text. Example: ```text Type CLOSE 2024-2025 to confirm. ``` The confirmation screen should clearly state: ```text The old school year will become read-only. A new school year will become active. Students will be promoted based on the preview. Unpaid parent balances will be transferred to the new year. This action will be logged. ``` --- ## 5. Pre-Closure Validation Before closure, validate the following: ```text No unposted payments No unresolved payment allocations No invalid or duplicate parent accounts No active year conflict No overlapping new school year dates No students missing promotion decisions No draft invoices that should be posted No financial inconsistencies between invoice amount, paid amount, and balance due Closing user has the required permission ``` Example validation response: ```json { "can_close": false, "errors": [ "12 payments are not allocated.", "3 students have no promotion decision.", "New school year overlaps with 2024-2025." ] } ``` Closure must be blocked if critical errors exist. --- ## 6. Read-Only Enforcement Closed years must be protected in both the frontend and backend. ### 6.1 Backend Guard Every create, update, or delete endpoint touching year-based records must check the school-year status. ```js async function assertSchoolYearEditable(schoolYearId) { const schoolYear = await SchoolYear.findById(schoolYearId); if (!schoolYear) { throw new Error("School year not found."); } if (schoolYear.status !== "active") { throw new Error("This school year is closed and read-only."); } } ``` Apply the guard to: ```text attendance grades exams invoices payments payment allocations student enrollments class assignments teacher assignments fee structures discipline records imports bulk updates ``` Backend enforcement is mandatory. Frontend-only protection is not security. --- ### 6.2 Frontend Read-Only Mode When the selected school year is not active: ```js const isReadOnly = selectedSchoolYear.status !== "active"; ``` Disable: ```text Add Edit Delete Import Submit Generate invoices Record payments Change class Promote student Bulk update ``` Show a clear banner: ```text Viewing school year 2024-2025. Read-only mode. ``` --- ## 7. School-Year Selector Add a global school-year selector in the application header. Example: ```text School Year: [2025-2026 Current ▼] ``` Dropdown options: ```text 2025-2026 Current 2024-2025 Closed 2023-2024 Archived ``` Every page must filter by the selected `school_year_id`. Example: ```sql SELECT * FROM student_enrollments WHERE school_year_id = :selectedSchoolYearId; ``` Never mix data across years unless the report specifically requires it. --- ## 8. Parent Balance Transfer Logic ### 8.1 Rule At closure, unpaid balances must be calculated by `parent_id`. Formula: ```text Parent unpaid balance = SUM(invoice.balance_due) WHERE invoice.parent_id = parent_id AND invoice.school_year_id = old_school_year_id AND invoice.status IN ('unpaid', 'partially_paid') AND invoice.balance_due > 0 ``` SQL: ```sql SELECT parent_id, SUM(balance_due) AS unpaid_balance FROM invoices WHERE school_year_id = :oldSchoolYearId AND status IN ('unpaid', 'partially_paid') AND balance_due > 0 GROUP BY parent_id; ``` --- ### 8.2 Transfer Process For each parent with unpaid balance: 1. Calculate total unpaid balance by `parent_id`. 2. Collect source unpaid invoice IDs. 3. Create or update the new-year parent account. 4. Set the transferred amount as the new-year opening balance. 5. Create a new-year invoice with `invoice_type = 'old_balance'`. 6. Create a `parent_balance_transfers` record. 7. Link the new old-balance invoice to the transfer record. 8. Keep old invoices unchanged and locked in the closed year. --- ### 8.3 Recommended Handling Use both: ```text parent_accounts.opening_balance ``` and: ```text invoices.invoice_type = old_balance ``` But do not double-count them. Recommended rule: ```text opening_balance = informational and audit value old_balance invoice = collectible amount current_balance = sum(new-year invoices) - sum(new-year payments) ``` The old-balance invoice is what parents pay against in the new year. --- ### 8.4 Old-Balance Invoice Example ```sql INSERT INTO invoices ( parent_id, student_id, school_year_id, amount, paid_amount, balance_due, status, invoice_type, description, balance_transfer_id, created_at, updated_at ) VALUES ( :parentId, NULL, :newSchoolYearId, :unpaidBalance, 0, :unpaidBalance, 'unpaid', 'old_balance', 'Previous year unpaid balance transferred to new school year', :balanceTransferId, NOW(), NOW() ); ``` `student_id` should be `NULL` because this is a parent-level debt. --- ## 9. Handling Credit Balances If a parent overpaid in the old year, the system should transfer the credit to the new year. Example: ```text Old year balance: -100.00 New year opening credit: -100.00 ``` The credit can reduce future invoices. Credit transfer logic should be handled separately from unpaid balance transfer. Recommended behavior: ```text Positive balance = amount parent owes Negative balance = credit parent owns Zero balance = no transfer ``` --- ## 10. Closure Transaction The entire close-year process must be atomic. Pseudo-code: ```js async function closeSchoolYear({ oldSchoolYearId, newSchoolYearData, promotionRules, adminUserId }) { return await db.transaction(async (trx) => { const oldYear = await SchoolYear.findById(oldSchoolYearId, trx); if (!oldYear || oldYear.status !== "active") { throw new Error("Only the active school year can be closed."); } await validateYearCanBeClosed(oldSchoolYearId, trx); const newYear = await SchoolYear.create({ name: newSchoolYearData.name, start_date: newSchoolYearData.startDate, end_date: newSchoolYearData.endDate, status: "active", is_current: true }, trx); await SchoolYear.update(oldSchoolYearId, { status: "closed", is_current: false, closed_at: new Date(), closed_by: adminUserId }, trx); await promoteStudents({ fromSchoolYearId: oldSchoolYearId, toSchoolYearId: newYear.id, promotionRules, trx }); await transferParentBalances({ fromSchoolYearId: oldSchoolYearId, toSchoolYearId: newYear.id, adminUserId, trx }); await copyReusableAcademicSettings({ fromSchoolYearId: oldSchoolYearId, toSchoolYearId: newYear.id, trx }); await AuditLog.create({ user_id: adminUserId, action: "CLOSE_SCHOOL_YEAR", record_id: oldSchoolYearId, metadata: { from_school_year_id: oldSchoolYearId, to_school_year_id: newYear.id } }, trx); return newYear; }); } ``` If anything fails, rollback everything. --- ## 11. Parent Balance Transfer Function Example using a Knex-like query builder: ```js async function transferParentBalances({ fromSchoolYearId, toSchoolYearId, adminUserId, trx }) { const balances = await trx("invoices") .select("parent_id") .sum("balance_due as unpaid_balance") .where("school_year_id", fromSchoolYearId) .whereIn("status", ["unpaid", "partially_paid"]) .where("balance_due", ">", 0) .groupBy("parent_id"); for (const row of balances) { const parentId = row.parent_id; const unpaidBalance = Number(row.unpaid_balance); if (unpaidBalance <= 0) continue; const existingTransfer = await trx("parent_balance_transfers") .where({ parent_id: parentId, from_school_year_id: fromSchoolYearId, to_school_year_id: toSchoolYearId }) .first(); if (existingTransfer) { throw new Error(`Balance already transferred for parent ${parentId}.`); } const sourceInvoices = await trx("invoices") .select("id", "amount", "paid_amount", "balance_due") .where("school_year_id", fromSchoolYearId) .where("parent_id", parentId) .whereIn("status", ["unpaid", "partially_paid"]) .where("balance_due", ">", 0); let parentAccount = await trx("parent_accounts") .where({ parent_id: parentId, school_year_id: toSchoolYearId }) .first(); if (!parentAccount) { const insertedAccounts = await trx("parent_accounts") .insert({ parent_id: parentId, school_year_id: toSchoolYearId, opening_balance: unpaidBalance, current_balance: unpaidBalance, created_at: new Date(), updated_at: new Date() }) .returning("*"); parentAccount = insertedAccounts[0]; } else { await trx("parent_accounts") .where({ id: parentAccount.id }) .update({ opening_balance: trx.raw("opening_balance + ?", [unpaidBalance]), current_balance: trx.raw("current_balance + ?", [unpaidBalance]), updated_at: new Date() }); } const insertedTransfers = await trx("parent_balance_transfers") .insert({ parent_id: parentId, from_school_year_id: fromSchoolYearId, to_school_year_id: toSchoolYearId, amount: unpaidBalance, status: "transferred", source_summary: JSON.stringify({ unpaid_invoice_ids: sourceInvoices.map(invoice => invoice.id), invoices: sourceInvoices, remaining_balance: unpaidBalance }), created_by: adminUserId, created_at: new Date() }) .returning("*"); const transfer = insertedTransfers[0]; await trx("invoices").insert({ parent_id: parentId, student_id: null, school_year_id: toSchoolYearId, amount: unpaidBalance, paid_amount: 0, balance_due: unpaidBalance, status: "unpaid", invoice_type: "old_balance", description: `Previous year unpaid balance transferred from school year ${fromSchoolYearId}`, balance_transfer_id: transfer.id, created_at: new Date(), updated_at: new Date() }); } } ``` --- ## 12. Payments After Closure Recommended rule: ```text Payments for closed years cannot be posted directly to the closed year. Payments must be posted in the active year against the transferred old-balance invoice. ``` Example: ```text Old year unpaid balance: 400.00 New year old-balance invoice: 400.00 Parent pays: 100.00 Remaining old-balance invoice: 300.00 ``` The old year remains historically accurate and read-only. --- ## 13. API Endpoints Recommended endpoints: ```text GET /api/school-years GET /api/school-years/:id/summary POST /api/school-years/:id/validate-close POST /api/school-years/:id/preview-close POST /api/school-years/:id/close POST /api/school-years/:id/reopen GET /api/school-years/:id/parent-balances GET /api/school-years/:id/promotion-preview ``` Example close request: ```json { "new_school_year": { "name": "2025-2026", "start_date": "2025-09-01", "end_date": "2026-06-30" }, "promotion_rules": { "default_action": "promote", "exceptions": [ { "student_id": 104, "action": "repeat", "target_grade_id": 3 }, { "student_id": 220, "action": "graduate" } ] }, "transfer_unpaid_balances": true } ``` --- ## 14. Permissions Recommended permissions: ```text school_year.view school_year.close school_year.reopen school_year.archive school_year.view_closed finance.transfer_balances finance.view_parent_balances ``` Recommended roles allowed to close years: ```text Super Admin Director Principal Finance Admin ``` Teachers and normal staff should be allowed to view closed years only if their role permits it. --- ## 15. Reports ### 15.1 Closing Report Should include: ```text Closed school year New school year Closed by Closed at Total students promoted Total students repeated Total students graduated Total students withdrawn/transferred Total unpaid balance transferred Total credit transferred Number of parents with transferred balances ``` --- ### 15.2 Parent Balance Transfer Report Should include: ```text Parent ID Parent name Old school year New school year Old unpaid amount New old-balance invoice ID Transfer date Transfer status Source invoice IDs ``` --- ### 15.3 Parent Statement When viewing a parent in the new year, show: ```text Opening balance from previous school year New year invoices Payments Credits Current balance ``` Example: ```text Opening balance from 2024-2025: 400.00 New year tuition: 1,000.00 Payments: 250.00 Current balance: 1,150.00 ``` --- ## 16. Edge Cases ### Parent Has Multiple Students Group all unpaid balances by parent. Correct: ```text Student A unpaid: 200.00 Student B unpaid: 300.00 Parent transferred balance: 500.00 ``` --- ### Parent Has Credit Balance Transfer credit to the new year. ```text Old year balance: -100.00 New year opening credit: -100.00 ``` --- ### Parent Has Zero Balance Do not create a transfer. --- ### Student Is Not Continuing The parent balance still transfers if the parent owes money. The debt belongs to the parent account, not to whether the child continues enrollment. --- ### Payment Happens During Closure The system should temporarily block payment posting during the closure transaction. Recommended protected operations during closure: ```text payment posting invoice generation enrollment changes student promotion changes fee changes bulk imports ``` --- ### Closure Fails Halfway Rollback everything. There must be no partial new year, half-promoted students, or duplicated transferred invoices. --- ## 17. Implementation Phases ### Phase 1: Data Model Preparation Deliverables: ```text school_years table student_enrollments table parent_accounts table parent_balance_transfers table balance_transfer_id on invoices school_year_id added to all academic and financial records ``` Acceptance criteria: ```text All year-based records can be filtered by school_year_id Only one active year exists Parent balances can be calculated by parent_id and school_year_id ``` --- ### Phase 2: Read-Only Enforcement Deliverables: ```text Backend school-year edit guard Frontend read-only mode Global school-year selector Closed-year banner Permission checks ``` Acceptance criteria: ```text Users can view closed years Users cannot modify closed-year records API rejects closed-year write requests UI disables editing for closed years ``` --- ### Phase 3: Close-Year Preview Deliverables: ```text Close-year validation endpoint Promotion preview Parent unpaid balance preview Warnings and blockers ``` Acceptance criteria: ```text Admin sees students to promote, repeat, graduate, or withdraw Admin sees unpaid balances grouped by parent_id System blocks closure if critical validation fails ``` --- ### Phase 4: Closure Execution Deliverables: ```text Transactional close-year service New-year creation Student promotion Reusable settings copy Parent balance transfer Old-balance invoice creation Audit logs ``` Acceptance criteria: ```text Old year becomes closed/read-only New year becomes active Student enrollments are created for the new year Parent unpaid balances appear in the new year Old-balance invoices are traceable to transfer records Duplicate transfers are blocked ``` --- ### Phase 5: Reports and Reconciliation Deliverables: ```text Closing report Parent balance transfer report Parent statement update Finance dashboard update ``` Acceptance criteria: ```text Finance can reconcile old unpaid balances against transferred balances Parent accounts show previous-year balances clearly No double-counting occurs No duplicate transfer records exist ``` --- ## 18. Testing Plan ### 18.1 Unit Tests Test: ```text Only one active year is allowed Closed year rejects edits Unpaid invoices are grouped by parent_id Old-balance invoice is created correctly Duplicate transfers are blocked Credit balances are handled correctly Zero balances are ignored Student promotion creates new enrollments Old enrollments remain unchanged ``` --- ### 18.2 Integration Tests Test full closure: ```text Create active school year Create students and parents Create invoices Add partial payments Close school year Verify old year is read-only Verify new year is active Verify students are promoted Verify parent balances are transferred Verify old-balance invoices are created Verify audit logs are created ``` --- ### 18.3 Financial Test Example Before closure: ```text Parent 1: Invoice A: 500.00, paid 300.00, balance 200.00 Invoice B: 400.00, paid 0.00, balance 400.00 Parent 2: Invoice C: 600.00, paid 600.00, balance 0.00 Parent 3: Invoice D: 300.00, paid 350.00, balance -50.00 ``` Expected after closure: ```text Parent 1 new year old_balance invoice: 600.00 Parent 2 no transfer Parent 3 new year credit: -50.00 ``` --- ## 19. Final Expected Behavior When the school year is closed: ```text Old school year: - status becomes closed - all records become read-only - unpaid invoices remain historically visible - financial records are locked New school year: - status becomes active - promoted students receive new enrollments - parent accounts are created or updated - unpaid balances from the old year are transferred by parent_id - old-balance invoices are created for collection - transferred balances are traceable to the original invoices ``` The key architecture rule is simple: ```text Academic history belongs to school_year_id. Financial responsibility belongs to parent_id. Debt transfer must be parent-level, traceable, and protected against duplication. ``` This gives the school a clean, auditable, and safe year-closure process instead of the usual manual spreadsheet ritual, which is basically accounting with a blindfold.