Skip to content

Domain Academic Year

Guard duplicated from ARCHITECTURE §14 — ARCHITECTURE wins on conflict.

UPCOMING | ACTIVE | CLOSED

Lifecycle: UPCOMING → ACTIVE → CLOSED (one-way transitions). There is no ENDED or ARCHIVED status — those were old names, dropped per ADR-001.

Max 1 ACTIVE academic year (service-layer guard)

Section titled “Max 1 ACTIVE academic year (service-layer guard)”

Before transitioning any academic_year.status to ACTIVE, verify no other row has status = 'ACTIVE'. If one exists, reject with the conflicting year’s label + id — admin must close the current ACTIVE year first.

This is a service-layer check (not a DB constraint). Implement it explicitly inside a transaction; do not assume the DB will catch it.

Implementation: packages/service/src/academic-years/activate-academic-year.ts

cloneAcademicYear copies ALL pricing data from the source year into a new row in ONE transaction:

  • student_base_prices (year × seg × tkt rows)
  • tutor_base_prices (year × seg × tkt × gol rows)
  • subject_tingkat_applicability (year × subject × tkt rows)
  • student_price_overrides (year × subject × seg × tkt rows)
  • tutor_price_overrides (year × subject × seg × tkt × gol rows)

New year starts status = 'UPCOMING'. Admin activates explicitly when ready.

Rejected if the source year has 0 rows across all 5 tables (“Cannot clone an empty year.”).

Does NOT close the source year.

Audit action: academic_year:clone. After payload includes rows_copied counts per table.

All pricing resolution (resolveStudentPrice / resolveTutorPrice) scopes by academic_year_id. The ACTIVE year is the default for new session approvals.

Full spec: plans/ARCHITECTURE.md §14.