Files
alrahma_sunday_school_api/docs/school_year_closure_plan.md
T
2026-06-05 17:16:38 -04:00

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

  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

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:

  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:

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:

  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.

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.