0%
balqalam Logo
balqalam
FeaturesCommunityPricingDocumentation
Login
  • Introduction
  • Help center
  • Common questions
  • Get Started
  • Localhost
  • Architecture
  • Structure
  • Pattern
  • Page
  • Layout
  • Content
  • Types
  • Config
  • Actions
  • Queries
  • Authorization
  • Validation
  • Form
  • Table
  • Detail
  • Card
  • Util
  • Hooks
  • List Params
  • Views
  • README.md
  • ISSUE.md
  • Technology Stack
  • Database
  • File
  • CDN Assets
  • Entry Points
  • Dashboard
  • Authentication
  • Credentials
  • OAuth
  • Flow Diagrams
  • Multi-Tenancy
  • Offline
  • Onboarding
  • Onboarding Videos
  • Add Values
  • Admission
  • Application
  • Attendance
  • Compliance
  • Profile
  • Exams
  • Exam Wizard
  • Timetable
  • Classrooms
  • Notifications
  • Conference
  • LMS (Lumos)
  • Finance
  • Fee Management
  • Invoice
  • Wallet
  • Salary
  • Payroll
  • Timesheet
  • Expenses
  • Budget
  • Receipt
  • Accounts
  • Banking
  • Reports
  • Dashboard
  • Permissions
  • Messages
  • Integration Flow
  • Provision
  • AI Document Processing
  • Document Intelligence
  • Internationalization
  • Translation
  • Translation Guide
  • Icons
  • Docs Factory
  • Inspiration
  • Listings
  • Teachers
  • Students
  • Catalog
  • Library
  • Contributing
  • Code of conduct
  • GitHub Workflow
  • Database Seeds
  • Database Safety
  • Test Accounts
  • Bulk import
  • Playwright
  • Prettier
  • Block Rebound
  • Transportation

Admission

PreviousNext

Admin-side admission management — application review, enrollment confirmation, merit list, section placement, and document-AI processing.

The admin admission flow runs from application review through enrollment confirmation. Confirming enrollment fires a single transaction that creates the Student, Guardians, FeeAssignments, and UserInvoices in one atomic step.

Applying is always free. The application wizard collects information only — no payment is taken at the application stage. Payment is triggered at two later points: the registration fee when the applicant accepts an offer, and tuition invoices after enrollment is confirmed.

Student intake — four channels, one pipeline

A student can enter the system four ways. All four funnel through one shared core, provisionStudent, so however a student is added they get the same complete result — and every student is born from a real Application, tagged with the channel it came through.

ChannelHow a student is addedApplication.channel
ApplyFamily completes the public application, staff review → offer → confirm enrollmentPORTAL
Admin singleAn admin creates one student in the /students wizardADMIN_DIRECT
Onboarding bulkCSV upload while setting up the schoolONBOARDING_IMPORT
/school/bulkCSV upload later, from the dashboardBULK_IMPORT
Backfill scriptRetroactive shadow application for a pre-unification studentLEGACY_BACKFILL

A student who signs themselves up is not a fifth channel — they apply. The internal-onboarding and newcomers self-registration flows are for the adults who join a school (teachers, staff, admins, parents); their student option was removed, and a self-registering student is sent to /{lang}/application so they enter the PORTAL pipeline like any other applicant. When a school has no open campaign, the closed screen offers the inquiry form instead, and the family lands in the Leads tab to be invited when admissions reopen.

The non-PORTAL channels create a "direct-admit" Application in the ADMITTED state, hung off a hidden per-school system campaign (named in the school's language). Everything below describes the PORTAL (public application → enrollment) path in detail; the other three channels reuse the same provisionStudent step from enrollment onward.

Every channel is trackable

Whichever way a student arrived, the school can find their application and follow it:

  • Applications tab lists every channel by default. A Channel column and facet filter (?channel=PORTAL,BULK_IMPORT — deep-linkable, multi-select, server-side) narrow back to the review queue. Until 2026-08-15 this tab hard-defaulted to PORTAL, so a direct-admit or imported student had a real application number that no screen could look up.
  • Student profile → application. The unified profile sidebar shows the application number and links to the detail page (ADMIN/STAFF only — the detail page is RBAC-gated, so the link is not offered to viewers who cannot open it). Legacy students with no application (pre-unification, backfill not yet run) simply show no row.
  • Application → student. The detail page shows the channel badge and, once a Student exists, a View student profile link — the loop closes both ways.
  • Direct admits are terminal. ADMITTED has no outgoing status transitions and confirmEnrollment refuses an already-admitted application server-side, so surfacing these rows cannot double-provision anyone. Their detail page hides the offer/registration-fee rows (that ledger never runs for them) and says fees are billed through Finance → Fees like any enrolled student.
  • The Enrollment tab keeps its PORTAL default — direct admits never pass through offer / registration-fee confirmation, so they have nothing to do there.

Expect the legacy backfill to be visible. Once scripts/backfill-legacy-applications.ts runs, every pre-unification student gains a LEGACY_BACKFILL row in the default view (~970 on the demo school). The filter is one click and lives in the URL.

Keeping the invariant true

"Every student is born from an Application" only holds while every write goes through provisionStudent, and it is easy to break by accident — a new feature reaches for db.student.create and the student it makes is invisible to every surface that reads Application.channel. Two guards enforce it:

  • Lint — a no-restricted-syntax rule in eslint.config.mjs rejects *.student.create/upsert/createMany outside a short, commented allowlist (STUDENT_CREATE_ALLOWLIST).
  • Test — src/tests/school-dashboard/listings/student-intake-invariant.test.ts re-scans the tracked tree and imports that same allowlist, so an inline eslint-disable or a drifting config still fails CI. It also fails if an allowlist entry stops matching any file.

Legitimate exceptions are the core itself, the seeds, snapshot restore, ops scripts, and createDraftStudent — a wizard draft carries a non-null wizardStep and is deliberately Application-less until completeStudentWizard runs the core. As of the self-registration change above, that is the complete list: no intake path is exempt any more.

Telling the family — one dispatcher, every channel

provisionStudent runs inside a caller-owned transaction and deliberately never notifies, so the telling happens after the commit. That work used to live inline in confirmEnrollment, which meant only PORTAL students were ever told anything: a student added through the wizard or a CSV import got a login, fees and invoices in silence, and so did their guardian.

src/lib/student-provisioning-notify.ts is that dispatch, shared by all five channels. It sends the student's account notice (with a password-setup link when the account is new), a fee_due notice covering the instalment schedule, and a guardian notice — falling back to the guardian's own address, since guardians made by createOrLinkGuardian are contact records with no login.

Two rules it must keep:

  • Placeholder addresses lose the email channel at row-creation time. A student with no address of their own gets a synthesized …@student.local. Skipping only the inline send is not enough — the email cron skips recipients with no address, and a placeholder is an address, so it would have tried to deliver later.
  • Bulk queues, single sends. delivery: "immediate" sends inline so an admin who just clicked "Create" sees the mail land. A CSV import always passes delivery: "queue", which writes the notification rows and lets the process-email-notifications cron drain them 50 at a time — a 500-row import spreads over hours instead of bursting. Bulk notification is opt-in and off by default; unchecked, an import stays as silent as it has always been.

Nobody falls out of the flow

A student with no academicGradeId was the pipeline's quiet failure mode: ensureStudentFeeAssignments short-circuits on a null grade, so no fees were assigned, no invoices raised, and neither the fee-due nor the fee-overdue cron ever chased them. A CSV section column that did not parse was enough to cause it, and nothing surfaced the result.

The students list now has an unplaced filter (no grade or no class), and the daily fee-due cron alerts admins weekly while any remain. Setting a grade from the list re-runs the fee assignment and backfills what was missed.

Invoices and receipts are also delivered now, not merely generated: the fee notice states the instalment schedule and stamps UserInvoice.sentAt, and a cleared payment links the payer to their receipt.

Students that predate the unification are linked retroactively by scripts/backfill-legacy-applications.ts. It mints a LEGACY_BACKFILL shadow Application per orphan and sets only Student.applicationId — never the other eleven provisioning steps, which those students already have. Dry run is the default; --execute writes, --school <id> scopes to one tenant. It is idempotent: the applicationId IS NULL filter plus the column's @unique mean a re-run only picks up what is still unlinked.

The journey, audited end to end (2026-08-15)

The whole path — apply → review → shortlist / reject → offer → the family's reply → registration fee → enroll → place in a section → tuition invoices → instalment reminders → payment → receipt — was traced across the admission, notification, finance and invoice blocks. The core money movement (fee assignment → invoices → allocation on payment → PAID/PARTIAL/OVERDUE) is sound; every break was at an edge. Fixed in that pass:

Journey stepWhat was brokenNow
Track the applicationApplications tab hard-defaulted to PORTAL; direct-admit / imported students unreachableEvery channel listed; Channel column + facet; profile ↔ application links both ways
Family pays registration fee at the office"Confirm registration payment" row action never appeared on first paint (registrationFeeMethod missing from the SSR select)In the select; the extra per-page lookup is gone
Offer link after the deadlineOnce the cron flipped the offer to EXPIRED the family's link was a bare 404Renders the "offer expired, contact the school" card
STAFF reviews an offerSaw a "Confirm Enrollment" button the server always rejectedGated on the same permission table the server asserts against (detail page + enrollment row)
Family pays online (Stripe / Tap)Only the student was told; guardians heard nothing; the school got no admin notice for a registration fee; guest applicants were skippedOne shared fan-out (finance/lib/payment-notify.ts) tells student + every guardian; registration-fee webhooks also alert ADMIN/ACCOUNTANT and reach guests by email
Family opens the "fees due" notificationLinked to /my-fees, which shows what is owed but cannot payLinks to /finance/fees/my (pay online, receipts); /my-fees gained a "Pay & view invoices" CTA; dead admin-only links removed from the family page
Admin adds a student, no fee structure for the gradeFees silently not assigned; no signal anywhereprovisionStudent emits NO_FEE_STRUCTURE_MATCH / FEES_SKIPPED_NO_GRADE; the wizard toasts them, the CSV import summarises them
Applicant reads the public status tracker in EnglishTimeline and checklist were hardcoded Arabic; EXPIRED was invisibleLocalized via the dictionary in the applicant's locale; EXPIRED shown
Enrollment email to the familySchool's language, not the family'sUses Application.lang
Offer email on a balqalam.com schoolHand-assembled …databayt.org URL pointed at the wrong roottenantUrl() (which now also survives being called outside a request — crons, webhooks)
Direct-admit student browses the public siteStatus banner greeted them with an application they never filedBanner is PORTAL-only
Invoice currencyRe-read the live school currency instead of the assignment's snapshotInherits the snapshot

Still open — flagged, not fixed, each with its reason:

  • Instalment reminders and OVERDUE detection read only FeeStructure.paymentSchedule (fee-due / fee-overdue crons). That JSON is populated only for auto-generated quarterly structures; a single-instalment or unscheduled admin-created fee has perfectly good UserInvoice.due_date values that neither cron reads — so it never reminds, never goes overdue, never accrues a late fee. The fix belongs in both crons (an invoice-due-date path). Both files were mid-edit by another session on 2026-08-15.
  • Fee due dates and "today" windows are pure UTC (buildQuarterlySchedule, todayBounds) — the same class of bug fixed in payroll; src/lib/timezone.ts has the helpers.
  • A withdrawn / archived student's open fees keep accruing fines and reminders — the archivedAt: null filter was in flight in the other session's cron edits.
  • No rejection reason — fixed 2026-09-04: a rejection or waitlist asks the reviewer for an optional note, stored on reviewNotes, carried in the family's notice and shown on the status tracker.
  • No refund path (PaymentStatus.REFUNDED is displayed, never set) and withdrawal does not void invoices — feature work.
  • One parent account, two children — provisionStudent reuses a Student by userId, and @@unique([schoolId, campaignId, userId]) blocks a second application in the same campaign regardless of allowMultipleApplications. Needs a product decision on account model.
  • Purging a student leaves UserInvoice rows orphaned (feeAssignmentId is ON DELETE SET NULL) and keeps the User.
  • Bulk section placement exists only in the dictionary; placement is one row at a time.
  • groupLabels / i18nGroupLabels in every wizard config render no text — FormFooter uses the array only for its length. Localizing them further is wasted until the footer renders labels.

The production pass, 2026-09-04

A second end-to-end trace — wizard, offer, finance, notifications, roles, i18n/RTL, mobile — after the 08-15 audit. Six commits on main. Fixed:

Journey stepWhat was brokenNow
Family accepts; the office is slow to confirmThe daily cron flipped every SELECTED offer past its deadline to EXPIRED — accepted and paid ones included; confirmEnrollment then refused themOnly unaccepted offers lapse. The deadline binds acceptance; the cron, confirmEnrollment, the offer page and the payment actions all apply the same rule
Registration fee booked at enrollmentThe ledger step matched the student's annual fee assignment and flipped it to PAID on a deposit; the fee crons stopped chasing the yearPARTIAL unless the payment covers the balance; the Payment snapshots a currency
Offer quotes the registration feeSummed every matching structure, pricing variants included; the school-wide arm had no isAutoGenerated filter — one number on the offer, another on the invoicesoffer/fee-structures.ts picks the set enrollment will assign (one variant per grade) for both the preview and the amount due
Two admins confirm at onceRead-then-write; only the late Student.applicationId unique index caught itStatus-guarded updates for enrollment and payment confirmation
Family clicks a notificationDeep-linked to /admission (the staff tree — role gate); leads notices linked routes that do not existApplicant dashboard / offer page; Leads tab; dashboard home for placement and guardian notices
Family reads a status notice or the offer mailSchool's languageApplication.lang, school preference as fallback
Email link on a balqalam.com schoolHand-assembled …databayt.org host, no locale (Stripe/Tap return URLs too)Root-aware, locale-prefixed; return URLs via tenantUrl()
Family turned email off for a typeThe inline send consulted only the caller's channel listSame per-channel gate the cron drain applies
Cash / bank / wallet fee confirmedOnly the applicant heard; STAFF saw the confirm item and got FORBIDDEN; Bankak / Cashi intents had no confirm item at allThrough the shared settler (offer/settle.ts): ADMIN + ACCOUNTANT told; item gated on recordPayment; one shared rail list
"Fees Pending" statCounted the vestigial application fee — everyone pending, foreverRegistration fee
ACCOUNTANT / STAFF row menusStatus menu, Assign Section and Generate Merit List offered to roles the server refused; no sidebar entry for ACCOUNTANT / DEVELOPERGated on the permission table; sidebar mirrors ADMISSION_VIEW_ROLES
Grid view · exportRaw status enum on cards; export labelled in every toolbar but never wiredTranslated labels; Applications + Enrollment CSV with localized headers, role-gated
Rejection / waitlistContentless notice; reviewNotes never writtenOptional note dialog → notice + status tracker
Status trackerA bare "Selected"; a vestigial application-fee checklist itemOffer link; offer-accepted + registration-fee items; the school's note
Manual payment intentLived only in component state — a reload sent the family back to the picker to record (and be quoted) twice; no receipt for the accountant to checkRestored from the server; receipt upload for bank / wallet rails, linked from the enrollment row and the detail page
Re-offer (EXPIRED / WAITLISTED → SELECTED)Kept a stale acceptance and unpaid intent; the token could already have lapsed, so the dashboard hid the offerReset (never a paid fee); token outlives the new deadline
OTP verificationLooked up by application number alone — five wrong codes locked the real applicant outBound to the email the code went to
RTL / mobilertl:flex-row-reverse double reversals in the wizard header and cards; campaign form 3 columns at 360px; ar-SA (Hijri) dates; dialogs without a scroll capFixed
Family returns after the campaign window closed/application answered "Enrollment is currently closed" the moment the campaign's end date passed — hiding a submitted application, a live offer and the fee stateTheir applications render first; only a family with nothing on file sees the closed page, and "start a new application" gives way to a closed notice
STAFF / ACCOUNTANT click Admission in the sidebarThe link is the campaigns index, admin-only in the route matrix — a dead end at /unauthorizedThe index lands non-admin roles on the Applications tab

Still open after this pass — product decisions and follow-ups, each with its reason:

  • One parent account, two children (unchanged): provisionStudent promotes Application.userId from USER to STUDENT, so a parent who applied on their own account logs back in as their child, and @@unique([schoolId, campaignId, userId]) blocks a second application in the same campaign (submitApplication already maps the collision to APPLICATION_DUPLICATE). Needs an account-model decision: a separate student User + the GUARDIAN role for the applicant.
  • Entrance / interview scheduling carries no date, time or venue — the schema has no columns, so both statuses notify with the generic message. Needs interviewAt / interviewLocation (DDL) and a scheduling dialog.
  • No EXPIRED notice to the family or the admin when the cron flips an unaccepted offer.
  • Tours: no confirmation template exists for the family (only cancel / reschedule builders), and no day-before reminder.
  • Links built outside a request (crons, webhooks) fall back to the primary root domain — a balqalam.com school's cron-sent links still carry databayt.org. Needs the school's root recorded on the School row.
  • Admission notification copy lives in inline bilingual constants, not the dictionary / NotificationTemplate table — invisible to the tooling, unoverridable per school.
  • Registration fee materializes only at enrollment (a Payment needs a Student): a cash fee confirmed for a family that later withdraws is never on the books.
  • createJournalEntry inside confirmEnrollment's transaction uses the global client — the ledger entry commits independently of the enrollment.
  • A started Stripe checkout is not expired when the family switches to a manual rail.
  • An accepted-but-unpaid offer never lapses — the price of the expiry fix: the cron exempts accepted offers, so a family that accepts and then never pays holds the seat until an admin withdraws it. Needs a payment deadline distinct from the acceptance deadline, with its own reminder and lapse.
  • WhatsApp / SMS are unused by admission; instalment reminders and OVERDUE detection still read only paymentSchedule, in UTC (unchanged from 08-15).

The intake pass, 2026-09-05

A third trace, this time of all four intake channels to the assembly point and of everything after it, verified in the browser as ADMIN and as the applicant on the local demo (wizard → credentials dialog → Applications tab → Assign Section; portal SUBMITTED → SHORTLISTED → SELECTED → family accepts → Confirm Enrollment → student created, graded, notified). Fixed:

WhereWhat was brokenNow
Production cronsvercel.json is crons: []; only the conference jobs were bridged — fee-due, fee-overdue, the email drain and document jobs never ranadmission-crons.yml bridges the four; the email drain has a 3-day age gate that retires the stale backlog before its first send
Wizard actionsgetStudentForWizard had no auth(); draft / complete / delete had no role checkOne guard (wizard/authorize.ts) for every wizard action
Editing an enrolled studentSave re-ran the core: new student code, new shadow Application (orphaning a portal one), re-sent welcome mailThe finisher recognises a provisioned row; the core reuses code / application / user
Editing a legacy studentA row with no application link (most pre-unification students) still re-ran the core, and the "one parent" gate — matching only the English type names — then locked the Save on the last stepAny row past the wizard saves and returns to the list; the gate guards provisioning only; parent types match by role
Guardian typescreateOrLinkGuardian upserted by exact English name, so Arabic-seeded schools grew a second father/mother row and the edit form showed empty parentsReuses the school's existing spelling (الأب, Father, father are one role); the guardian form fills once the parents load
Wizard credentialsThe minted temp password was returned and never shownOne finisher opens the shared credentials dialog on the list page
Guardian WhatsAppStored in *Email columns, written to Guardian.emailAddress at enrollment; a shared household number merged two parentsClassified by shape; createOrLinkGuardian writes a whatsapp phone row; the detail page labels it
Placement for direct admitsOnly the Enrollment tab (PORTAL) could place; an empty applyingForClass matched every sectionOne PlacementDialog, also on the students list; matching by grade id
Remindersfee-due's invoice arm skipped every invoice whose assignment existed; fee-overdue read only paymentSchedule; archived students were chasedSkip only assignments reminded this run; overdue on invoice due dates; archived excluded
Arabic grade 12extractGradeNumber("الصف الثاني عشر") returned 2Longest ordinal wins
Public grade pickerStatic KG1–12 regardless of the schoolNarrowed to the school's grades
Tables after an actionRows kept their mounted state after router.refresh(); a confirmed row still offered Confirm EnrollmentPlatform tables adopt the server's rows when their content changes, and refetch instead while a search or filter is active

Still open after this pass: a student with a grade and no fee assignment (the demo campaign is 2026-2027, every active structure is 2025-2026) is invisible to the reminders and the unplaced chip — only a warning toast at creation says so; the account-model, interview-date, tour-mail and out-of-request-link items from 2026-09-04 are unchanged.

Flow

A family moves through six stages. The first four happen on the public website (driven by the family); the last two happen in the staff dashboard.

Two invariants hold everywhere: applying is always free (the only payment is the registration fee, after an offer is accepted), and schools never see each other's data (every read and write is scoped to one school).

Public portal

Ask a question, book a tour, or check an application's status.

Application wizard

A logged-in family completes a five-step form that auto-saves and is resumable.

Staff review

Only staff-side roles may open the dashboard. Terminal statuses cannot re-offer.

Offer and registration fee

The one payment in the flow, taken only after an offer is accepted.

Enrollment

One all-or-nothing transaction turns an accepted applicant into a placed student.

Behind the scenes

Settings, AI document reading, reminder jobs, and notifications.

Structure

The admission feature follows the mirror pattern — every route under app/[lang]/ has a matching feature directory under components/, so if you know the URL you know where the code lives.

src/— Source code directory
app/— Next.js App Router (Routing & Layouts)
[lang]/— i18n support
abc/— URL route: /abc
page.tsx— Route entry point
layout.tsx— Route layout
components/— Component Logic (Mirrors app structure)
abc/— Mirrors app/[lang]/abc/
content.tsx— Page UI: headings, sections, layout
actions.ts— Server actions: validate, mutate
config.ts— Enums, option lists, defaults
validation.ts— Zod schemas & refinements
types.ts— Domain and UI types
form.tsx— Typed forms (RHF)
card.tsx— KPIs, summaries, quick actions
all.tsx— List view with table, filters
detail.tsx— Detail view with sections
column.tsx— Table column builders
use-abc.ts— Feature hooks
README.md— Feature purpose, APIs, decisions
ISSUE.md— Known issues and follow-ups
atom/— Atomic UI components
template/— Reusable layout templates
ui/— Base UI components (shadcn/ui)

Routes

TabRoutePurpose
Campaigns/{lang}/admissionCreate / manage admission campaigns
Applications/{lang}/admission/applicationsReview submitted applications
Merit list/{lang}/admission/meritRank applications by scores
Enrollment/{lang}/admission/enrollmentConfirm enrollment for selected apps
Leads/{lang}/admission/leadsInquiries + tour bookings (ADMIN/ACCOUNTANT read-only)
Settings/{lang}/admission/settingsConfigure admission portal
Detail/{lang}/admission/applications/{id}Single application view

Status flow

StatusTriggerWhat happens
DRAFTSession createdForm data being collected
SUBMITTEDApplicant submitsApplication number generated, email sent
UNDER_REVIEWAdmin updatesreviewedAt + reviewedBy set
SHORTLISTEDAdmin updatesApplicant notified
ENTRANCE_SCHEDULEDAdmin schedulesEntrance exam date set
INTERVIEW_SCHEDULEDAdmin schedulesInterview date set
SELECTEDAdmin selectsOffer created (configurable expiry), payment link
EXPIREDDaily job (offer lapsed)Offer past expiry auto-flips; admin can re-offer
WAITLISTEDAdmin waitlistsApplicant notified
REJECTEDAdmin rejectsApplicant notified
ADMITTEDAdmin confirms enrollmentStudent + Guardians + Fees + Invoices created
WITHDRAWNApplicant or admin withdrawsApplication closed

Enrollment transaction

confirmEnrollment() keeps the application-specific work (status guard, ENR-{yy}-{seq} enrollment number, the registration-fee Payment + ledger entry) and delegates the actual student creation to the shared provisionStudent() core (src/lib/student-provisioning.ts) — the same core the admin wizard and bulk import call. Inside one db.$transaction:

  1. Set application status ADMITTED with the enrollment number.
  2. provisionStudent():
    • Resolve AcademicGrade, generate the per-school student code (YYGGNNNN).
    • Create User (role STUDENT) if the applicant was a guest; promote USER → STUDENT otherwise. User is created before fees — invoices no-op without a userId.
    • Create the Student (or reuse a wizard draft), linking userId + applicationId.
    • Match applyingForClass → YearLevel; create StudentYearLevel + set academicGradeId.
    • Auto-assign active FeeStructures → FeeAssignment rows (PENDING) and one UserInvoice per assignment (via ensureStudentFeeAssignments → ensureInvoicesForAssignment).
    • Create Guardian + StudentGuardian + GuardianPhoneNumber; copy application documents to StudentDocument.
  3. Materialize the registration-fee Payment + journal entry when the registration fee was paid.

Post-transaction (best-effort, owned by the caller): notify student + guardians, mint a password-reset link for a new guest user, dispatch fee_due notifications, compute a section suggestion. The student is created with wizardStep: null and status: ACTIVE. The admin wizard and CSV import call provisionStudent() the same way (with origin ADMIN_DIRECT / BULK_IMPORT / ONBOARDING_IMPORT), which is what gives every channel the same login + invoices.

Application → Student data transfer

ApplicationStudentNotes
firstName / middleName / lastNamesameDirect copy
dateOfBirth, gender, nationalitysameDirect copy
emailemailDirect copy
phone / alternatePhonemobileNumber / alternatePhoneDirect copy
address, city, state, postalCode, countrycurrentAddress, …Direct copy
photoUrlprofilePhotoUrlProfile picture
previousSchool / previousClasspreviousSchoolName / previousGradeDirect copy
previousMarks + previousPercentage + achievementspreviousAcademicRecordCombined string
fatherName / motherNameGuardian recordCreates Guardian + StudentGuardian
fatherPhone / guardianPhoneemergencyContactPhoneFirst available used
guardianRelationemergencyContactRelationFalls back to "Parent"
documents (JSON)StudentDocument recordsEach document creates a row
applyingForClassacademicGradeIdVia YearLevel → AcademicGrade

Guardian auto-creation

For each parent (father, mother):

  1. Split name into firstName + lastName (last space).
  2. Upsert GuardianType (father, mother).
  3. Create Guardian (or find by email).
  4. Create StudentGuardian join row with occupation, isPrimary.
  5. Create GuardianPhoneNumber if phone provided.

Father is primary; if no father, mother becomes primary.

Section placement

After enrollment, place the student in a section:

  • Enrollment response includes suggestedSectionId if exactly one section has capacity for the student's grade.
  • Lists sections with available capacity for that grade.
  • placeStudentInSection() assigns the seat, then gives the student the LMS courses of the section's grade via syncStudentSubjectEnrollments().

Wizard editing

Enrolled students can be edited via the student wizard (Students table → Actions → Edit). All application data is pre-populated.

updateStudentWizardStep only updates wizardStep for draft students (non-null wizardStep) — prevents accidental reversion of enrolled students. Final button reads "Save", not "Create".

Merit list

Admins enter entrance and interview scores directly on the merit table (0–100 per score). generateMeritList() computes a weighted meritScore for each application:

  • Entrance exam — 60%
  • Interview — 40%

Applications are ranked by meritScore descending; unscored applications sort last. Scores are batch-written via updateApplicationScores(). The merit table covers shortlisted, selected, and waitlisted applications.

Offer → accept → pay → enroll flow

Once an applicant is SELECTED:

  1. An offer is created with a configurable expiry (offerExpiryDays, default 14).
  2. The applicant logs in via a token'd offer URL (login preserves the full callback path).
  3. The applicant pays the registration fee through the school's gateway (Tap for UAE/Gulf, Stripe elsewhere). The application wizard shows an informational payment preview — no payment is collected at the application stage.
  4. On payment success, the admin sees a registration-fee paid banner and can proceed.
  5. Admin clicks "Confirm enrollment" → confirmEnrollment() transaction fires (see Enrollment transaction).
  6. Post-enrollment the admin uses the Placement dialog on the enrollment table to pick a section (seat counts shown); placeStudentInSection() puts the student on the section's roster and timetable and gives them their grade's subjects in Lumos.

Abandoned-checkout retry is unblocked — applicants who navigated away can return and complete payment.

Leads tab

The Leads tab (/{lang}/admission/leads) surfaces:

  • Inquiries — contact form submissions with name, email, phone, interest grade, source, and follow-up status.
  • Tour bookings — scheduled visits with attendee count, status (PENDING / CONFIRMED / CANCELLED), and reschedule/cancel actions.

ADMIN and STAFF roles have full action access; ACCOUNTANT has read-only access. New inquiries and bookings trigger in-app notifications to ADMIN + STAFF. Tour bookings guard against overselling (TOCTOU-safe) and honour the enableTourBooking campaign flag; cancel/reschedule decrements the attendee count correctly.

Document-AI processing

Uploaded applicant documents (ID scans, transcripts) are processed by an extraction queue. The cron /api/cron/process-document-jobs runs every 10 minutes and drains pending jobs. classifyDocument() and getDocumentProcessingStatus() are RBAC-gated. AI usage is budget-checked (canUseAI) before each call; the bank-receipt Zod schema is optional so partial extractions do not fail the whole job.

Settings

{
  allowMultipleApplications: false,
  requireDocuments: true,
  // applicationFee removed — applying is always free
  offerExpiryDays: 14,           // 1-90
  autoEmailNotifications: true,
  enableOnlinePayment: false,
  paymentMethods: ["stripe", "cash"],
  bankDetails: { bankName, accountName, accountNumber, iban, swiftCode },
  cashPaymentInstructions: "",
  entranceWeight: 60,            // merit weights (sum=100)
  interviewWeight: 40,
}

Server actions (admin, auth + permission)

ActionPermission
getApplications()viewApplications
updateApplicationStatus()updateStatus
confirmEnrollment()confirmEnrollment
placeStudentInSection()confirmEnrollment
getAvailableSectionsForPlacement()confirmEnrollment
generateMeritList()manageMerit
updateApplicationScores()manageMerit
getCampaigns() / createCampaign() / updateCampaign() / deleteCampaign()viewCampaigns / manageCampaigns
getAdmissionSettings() / saveAdmissionSettings()manageSettings
fetchCampaignOptions()viewCampaigns
classifyDocument() / getDocumentProcessingStatus()viewApplications (RBAC-gated)

Multi-tenant safety

All queries scoped by schoolId. Submit flow resolves school from subdomain via getSchoolBySubdomain(); session is verified to belong to that school.

Key files

PurposePath
Provisioning coresrc/lib/student-provisioning.ts (shared by all 4 channels) + src/lib/system-campaign.ts (direct-admit Application + system campaign)
Dashboard actionssrc/components/school-dashboard/admission/actions.ts
Applications surfacesrc/components/school-dashboard/admission/applications-{table,columns}.tsx + application-detail-content.tsx
Enrollment / meritsrc/components/school-dashboard/admission/{enrollment,merit}-table.tsx
Leads tabsrc/components/school-dashboard/admission/leads/
Document AIsrc/components/school-dashboard/admission/ai/{classify,bank-receipt-schema}.ts
Settingssrc/components/school-dashboard/admission/settings/{actions,validation}.ts
Invoice utilitysrc/components/school-dashboard/finance/invoice/actions.ts
Schemaprisma/models/admission.prisma
Testssrc/components/school-dashboard/admission/__tests__/{actions,enrollment-pipeline}.test.ts + finance/invoice/__tests__/create-from-enrollment.test.ts

See also

  • Application — applicant-side flow
  • Multi-tenancy — schoolId scoping
  • Invoice — auto-generation from enrollment
  • Fees — fee assignment lifecycle
Add ValuesApplication

On This Page

Student intake — four channels, one pipelineEvery channel is trackableKeeping the invariant trueTelling the family — one dispatcher, every channelNobody falls out of the flowThe journey, audited end to end (2026-08-15)The production pass, 2026-09-04The intake pass, 2026-09-05FlowPublic portalApplication wizardStaff reviewOffer and registration feeEnrollmentBehind the scenesStructureRoutesStatus flowEnrollment transactionApplication → Student data transferGuardian auto-creationSection placementWizard editingMerit listOffer → accept → pay → enroll flowLeads tabDocument-AI processingSettingsServer actions (admin, auth + permission)Multi-tenant safetyKey filesSee also

Built by Databayt ·

Welcome to balqalam.

A great journey is about to begin.