24 KiB
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
- Only one school year can be active at a time.
- Closed school years are read-only.
- Every academic and financial record must be linked to a
school_year_id. - Historical records must not be overwritten during year closure.
- Student promotion must create new enrollments for the new year.
- Old enrollments must remain unchanged.
- Unpaid balances must be calculated by
parent_id. - Transferred balances must be traceable to the original year and original invoices.
- The closure process must run inside a database transaction.
- If one closure step fails, the entire operation must roll back.
- Only authorized users can close, reopen, or archive school years.
- Duplicate balance transfers must be prevented.
- Payments after closure should be posted in the new active year against the transferred balance invoice.
3. Required Data Model
3.1 school_years
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:
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.
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:
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.
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:
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.
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:
unpaid
partially_paid
paid
cancelled
void
Recommended invoice types:
tuition
transport
books
registration
old_balance
adjustment
3.5 parent_balance_transfers
This table records unpaid balances transferred from one school year to another.
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:
pending
transferred
reversed
Prevent duplicate transfers:
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:
{
"unpaid_invoice_ids": [101, 102, 140],
"total_due": 1200.00,
"total_paid": 800.00,
"remaining_balance": 400.00
}
3.6 audit_logs
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:
Current school year: 2024-2025
Status: Active
Only an active school year can be closed.
Step 2: Enter New School Year Details
Admin enters:
New school year name
Start date
End date
Example:
Name: 2025-2026
Start date: 2025-09-01
End date: 2026-06-30
Validation rules:
- Name must be unique.
- Start date must be before end date.
- New year must not overlap an existing year.
- There must not already be another active year.
Step 3: Student Promotion Preview
The system previews what will happen to students.
Possible actions:
Promote to next grade
Repeat same grade
Graduate
Transfer out
Withdraw
Hold for manual review
The preview should show:
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:
Parent ID
Parent name
Students under parent
Unpaid invoice count
Old unpaid balance
Credit balance, if any
Amount to transfer
Transfer status
Example:
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:
Type CLOSE 2024-2025 to confirm.
The confirmation screen should clearly state:
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:
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:
{
"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.
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:
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:
const isReadOnly = selectedSchoolYear.status !== "active";
Disable:
Add
Edit
Delete
Import
Submit
Generate invoices
Record payments
Change class
Promote student
Bulk update
Show a clear banner:
Viewing school year 2024-2025. Read-only mode.
7. School-Year Selector
Add a global school-year selector in the application header.
Example:
School Year: [2025-2026 Current ▼]
Dropdown options:
2025-2026 Current
2024-2025 Closed
2023-2024 Archived
Every page must filter by the selected school_year_id.
Example:
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:
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:
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:
- Calculate total unpaid balance by
parent_id. - Collect source unpaid invoice IDs.
- Create or update the new-year parent account.
- Set the transferred amount as the new-year opening balance.
- Create a new-year invoice with
invoice_type = 'old_balance'. - Create a
parent_balance_transfersrecord. - Link the new old-balance invoice to the transfer record.
- Keep old invoices unchanged and locked in the closed year.
8.3 Recommended Handling
Use both:
parent_accounts.opening_balance
and:
invoices.invoice_type = old_balance
But do not double-count them.
Recommended rule:
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
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:
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:
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:
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:
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:
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:
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:
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:
{
"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:
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:
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:
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:
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:
Opening balance from previous school year
New year invoices
Payments
Credits
Current balance
Example:
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:
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.
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:
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:
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:
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:
Backend school-year edit guard
Frontend read-only mode
Global school-year selector
Closed-year banner
Permission checks
Acceptance criteria:
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:
Close-year validation endpoint
Promotion preview
Parent unpaid balance preview
Warnings and blockers
Acceptance criteria:
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:
Transactional close-year service
New-year creation
Student promotion
Reusable settings copy
Parent balance transfer
Old-balance invoice creation
Audit logs
Acceptance criteria:
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:
Closing report
Parent balance transfer report
Parent statement update
Finance dashboard update
Acceptance criteria:
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:
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:
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:
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:
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:
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:
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.