Domain Academic Year
Guard duplicated from ARCHITECTURE §14 — ARCHITECTURE wins on conflict.
Academic Year Guard
Section titled “Academic Year Guard”Status enum
Section titled “Status enum”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
Clone semantics
Section titled “Clone semantics”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.
Pricing lookup uses academic_year_id
Section titled “Pricing lookup uses academic_year_id”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.