diff --git a/.gitignore b/.gitignore
index eb21281..38b1ec3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -56,3 +56,6 @@ next-env.d.ts
# Isolated pass component test bundle
.pass-test/
+
+# bank statements: never commit
+/STATEMENT*.xlsx
diff --git a/src/app/api/admin/registrations/payment-pass/route.ts b/src/app/api/admin/registrations/payment-pass/route.ts
new file mode 100644
index 0000000..906ddf7
--- /dev/null
+++ b/src/app/api/admin/registrations/payment-pass/route.ts
@@ -0,0 +1,102 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyRegistrationStaff } from '@/lib/auth-server';
+import { isFilterSafeEmail, readJsonObject } from '@/lib/security';
+import { isFee, MAX_FEE } from '@/lib/seats';
+
+export const runtime = 'nodejs';
+
+const MAX_NOTE_LENGTH = 300;
+const ALLOWED_FOR_MS = 7 * 24 * 60 * 60 * 1000;
+
+/**
+ * POST /api/admin/registrations/payment-pass { email, amount, note }
+ * Registration-Team/Admin only. The staff route for a student who paid when they shouldn't have been able to (a saved
+ * QR or the UPI ID while registrations were closed), after staff have checked the bank statement. It doesn't create a
+ * registration: the student still fills in the form and submits their proof, at `amount`, and it is reviewed as usual.
+ */
+export async function POST(req: NextRequest) {
+ try {
+ const auth = await verifyRegistrationStaff(req);
+ if ('error' in auth) {
+ return NextResponse.json({ error: auth.error }, { status: auth.status });
+ }
+
+ const parsed = await readJsonObject(req);
+ if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: parsed.status });
+ const { email, amount, note } = parsed.value;
+
+ const normalized = typeof email === 'string' ? email.toLowerCase().trim() : '';
+ if (!normalized || !isFilterSafeEmail(normalized)) {
+ return NextResponse.json({ error: 'Enter the email address the student signs in with.' }, { status: 400 });
+ }
+ if (!isFee(amount)) {
+ return NextResponse.json({ error: `The amount must be a whole number of rupees from 1 to ${MAX_FEE}.` }, { status: 400 });
+ }
+ const reason = typeof note === 'string' ? note.trim() : '';
+ if (!reason || reason.length > MAX_NOTE_LENGTH) {
+ return NextResponse.json({ error: `Add a note with the UTR you checked (up to ${MAX_NOTE_LENGTH} characters).` }, { status: 400 });
+ }
+
+ const { supabase, user } = auth;
+ const { data: profile, error: profileError } = await supabase
+ .from('profiles')
+ .select('id')
+ .eq('email', normalized)
+ .maybeSingle();
+ if (profileError) {
+ console.error('/api/admin/registrations/payment-pass profile lookup failed:', profileError);
+ return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
+ }
+ if (!profile) {
+ return NextResponse.json(
+ { error: 'No account with that email. Ask the student to sign in on the site once, then try again.' },
+ { status: 404 }
+ );
+ }
+ if (profile.id === user.id) {
+ return NextResponse.json({ error: 'You cannot allow your own payment. Ask another staff member.' }, { status: 403 });
+ }
+
+ const [active, counter] = await Promise.all([
+ supabase.from('registrations').select('registration_id').eq('user_id', profile.id).neq('status', 'rejected').limit(1).maybeSingle(),
+ supabase.from('registration_counter').select('phase').eq('id', 1).maybeSingle(),
+ ]);
+ if (active.error || counter.error || !counter.data) {
+ console.error('/api/admin/registrations/payment-pass lookup failed:', active.error || counter.error);
+ return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
+ }
+ if (active.data) {
+ return NextResponse.json({ error: `This student already has registration ${active.data.registration_id}.` }, { status: 409 });
+ }
+
+ // Only one pass counts at a time: the newest.
+ const now = new Date();
+ const { error: retireError } = await supabase
+ .from('payment_passes')
+ .update({ used_at: now.toISOString() })
+ .eq('user_id', profile.id)
+ .is('used_at', null);
+ if (retireError) {
+ console.error('/api/admin/registrations/payment-pass retire failed:', retireError);
+ return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
+ }
+ // pay_by is now: staff have seen the payment, so the form skips the payment details and goes to the proof.
+ const { error: insertError } = await supabase.from('payment_passes').insert({
+ user_id: profile.id,
+ phase: counter.data.phase,
+ amount,
+ pay_by: now.toISOString(),
+ submit_by: new Date(now.getTime() + ALLOWED_FOR_MS).toISOString(),
+ issued_by: user.id,
+ note: reason,
+ });
+ if (insertError) {
+ console.error('/api/admin/registrations/payment-pass insert failed:', insertError);
+ return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
+ }
+ return NextResponse.json({ success: true });
+ } catch (err: unknown) {
+ console.error('/api/admin/registrations/payment-pass error:', err);
+ return NextResponse.json({ error: 'Failed to allow the payment.' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/admin/seats/phase/route.ts b/src/app/api/admin/seats/phase/route.ts
new file mode 100644
index 0000000..716080b
--- /dev/null
+++ b/src/app/api/admin/seats/phase/route.ts
@@ -0,0 +1,51 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAdmin } from '@/lib/auth-server';
+import { readJsonObject } from '@/lib/security';
+import { nextPhaseError } from '@/lib/seats';
+
+export const runtime = 'nodejs';
+
+/**
+ * POST /api/admin/seats/phase { fee, total, left }
+ * Admins only: starts the next registration phase, the only way to reopen registrations (start_registration_phase,
+ * supabase/schema.sql section 20). Refused while a phase is open. Students already quoted an older fee keep it.
+ */
+export async function POST(req: NextRequest) {
+ try {
+ const auth = await verifyAdmin(req);
+ if ('error' in auth) {
+ return NextResponse.json(
+ { error: auth.status === 403 ? 'Only admins can start a registration phase.' : auth.error },
+ { status: auth.status }
+ );
+ }
+
+ const parsed = await readJsonObject(req);
+ if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: parsed.status });
+ const { fee, total, left } = parsed.value;
+ const invalid = nextPhaseError(fee, total, left);
+ if (invalid) return NextResponse.json({ error: invalid }, { status: 400 });
+
+ const { error } = await auth.supabase.rpc('start_registration_phase', {
+ p_fee: fee,
+ p_total: total,
+ p_left: left,
+ p_admin: auth.user.id,
+ });
+ if (error) {
+ // 55000: the current phase hasn't closed (trg_close_registration_phase_at_zero closes it at 0).
+ if (error.code === '55000') {
+ return NextResponse.json(
+ { error: 'The current phase is still open. Set seats left to 0 to close it first.' },
+ { status: 409 }
+ );
+ }
+ console.error('/api/admin/seats/phase failed:', error);
+ return NextResponse.json({ error: 'Failed to start the next phase.' }, { status: 500 });
+ }
+ return NextResponse.json({ success: true });
+ } catch (err: unknown) {
+ console.error('/api/admin/seats/phase error:', err);
+ return NextResponse.json({ error: 'Failed to start the next phase.' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/admin/seats/route.ts b/src/app/api/admin/seats/route.ts
index 6ab61ea..9fbf242 100644
--- a/src/app/api/admin/seats/route.ts
+++ b/src/app/api/admin/seats/route.ts
@@ -6,11 +6,14 @@ import { seatCountError } from '@/lib/seats';
export const runtime = 'nodejs';
-/** The counter as staff see it: the two numbers, and who last set them by hand and when. */
+/**
+ * The counter as staff see it: the numbers, the phase and its fee, whether it is closed, and who last set the numbers
+ * by hand and when.
+ */
async function readCounter(supabase: SupabaseClient) {
const { data, error } = await supabase
.from('registration_counter')
- .select('total_seats, seats_left, updated_by, updated_at')
+ .select('total_seats, seats_left, phase, fee_inr, closed_at, updated_by, updated_at')
.eq('id', 1)
.maybeSingle();
if (error || !data) return { error: error ?? new Error('registration_counter has no row'), counter: null };
@@ -26,12 +29,20 @@ async function readCounter(supabase: SupabaseClient) {
}
return {
error: null,
- counter: { total: data.total_seats, left: data.seats_left, updatedAt: data.updated_at, updatedBy },
+ counter: {
+ total: data.total_seats,
+ left: data.seats_left,
+ phase: data.phase,
+ fee: data.fee_inr,
+ closedAt: data.closed_at,
+ updatedAt: data.updated_at,
+ updatedBy,
+ },
};
}
const UNAVAILABLE =
- 'The seat counter is not set up yet. Run supabase/manual/seat_counter_and_reregistration.sql in Supabase.';
+ 'The seat counter is not set up yet. Run supabase/manual/seat_counter_and_reregistration.sql and registration_phases.sql in Supabase.';
/**
* GET /api/admin/seats
@@ -59,7 +70,7 @@ export async function GET(req: NextRequest) {
/**
* PATCH /api/admin/seats { total, left }
* Admins only: sets both numbers. The database keeps moving `left` from there as registrations come in and are
- * rejected.
+ * rejected. Setting `left` to 0 closes the phase. Nothing here reopens a closed one: only POST /api/admin/seats/phase.
*/
export async function PATCH(req: NextRequest) {
try {
diff --git a/src/app/api/id-card/route.ts b/src/app/api/id-card/route.ts
index 5ca7450..f39ed1f 100644
--- a/src/app/api/id-card/route.ts
+++ b/src/app/api/id-card/route.ts
@@ -18,7 +18,7 @@ import {
export const runtime = 'nodejs';
const TOO_LARGE = 'That photo is larger than 2 MB. Please choose a smaller photo of your ID card.';
-const ALREADY_UPLOADED = 'Your college ID card is already uploaded. Contact the registration helpdesk to change it.';
+const ALREADY_UPLOADED = 'Your college ID card is already uploaded. Contact us to change it.';
// Allow retries but stop bulk abuse of storage.
const idCardLimiter = createRateLimiter({ limit: 20, windowMs: 10 * 60 * 1000 });
diff --git a/src/app/api/pass/route.ts b/src/app/api/pass/route.ts
index b096e68..d0bd359 100644
--- a/src/app/api/pass/route.ts
+++ b/src/app/api/pass/route.ts
@@ -102,7 +102,7 @@ export async function POST(req: NextRequest) {
pass.token = decryptEntryToken(ciphertext, mine.pass, key);
if (!pass.token) {
console.error('/api/pass: stored token does not decrypt (wrong ENTRY_PASS_ENCRYPTION_KEY?)');
- return NextResponse.json({ error: 'Your pass could not be loaded. Please contact the help desk.' }, { status: 500 });
+ return NextResponse.json({ error: 'Your pass could not be loaded. Please contact us.' }, { status: 500 });
}
return NextResponse.json({ success: true, pass }, { headers: { 'Cache-Control': 'private, no-store' } });
} catch (err: unknown) {
diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts
index 5872b55..14e3d85 100644
--- a/src/app/api/register/route.ts
+++ b/src/app/api/register/route.ts
@@ -15,6 +15,7 @@ import {
PHONE_IN_USE_ERROR_MESSAGE,
} from '@/lib/validation';
import { ATTEMPTS_USED_UP_ERROR, attemptsLeft, isAttemptsUsedUpError } from '@/lib/rejections';
+import { REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats';
import {
classifyStudentType,
createRateLimiter,
@@ -22,7 +23,6 @@ import {
isFilterSafeEmail,
NEW_REGISTRATION_STATUS,
readJsonObject,
- REGISTRATION_FEE_INR,
validateRegistrationInput,
} from '@/lib/security';
@@ -234,6 +234,18 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 403 });
}
+ // What this submit is charged, and whether it may go ahead: the fee quoted when the student started paying (even if
+ // the phase has closed or the fee has risen since), or the current fee while a phase is open. Otherwise refused: they
+ // may have paid with a saved QR or the UPI ID, so staff check the bank statement and allow it by hand.
+ const { data: payment, error: paymentError } = await adminClient.rpc('resolve_registration_payment', { p_user_id: user.id });
+ if (paymentError || !payment) {
+ console.error('Payment lookup failed in /api/register POST:', paymentError);
+ return NextResponse.json({ error: 'Failed to record registration. Please try again.' }, { status: 500 });
+ }
+ if (!payment.allowed) {
+ return NextResponse.json({ code: 'registrations_closed', error: REGISTRATIONS_FULL_MESSAGE }, { status: 409 });
+ }
+
// A UPI transaction can only pay for one registration.
const { data: utrOwner } = await adminClient
.from('registrations')
@@ -285,7 +297,7 @@ export async function POST(req: NextRequest) {
ambassadorId = referral.ambassadorId;
}
- // 4. Status and fee are server constants, the same for every registrant (no free tier): any status, amount or
+ // 4. Status and fee are decided here, by the same rules for every registrant (no free tier): any status, amount or
// student_type in the body is ignored. Staff confirm or reject after checking the payment.
// 5. Insert Registration into Database (retrying if the random IV26-XXXX id or the generated referral code collides)
const basePayload = {
@@ -303,7 +315,9 @@ export async function POST(req: NextRequest) {
id_card_path: input.id_card_path,
id_card_uploaded_at: new Date().toISOString(),
utr: input.utr,
- amount: REGISTRATION_FEE_INR,
+ amount: payment.amount,
+ phase: payment.phase,
+ allowed_by: payment.allowed_by ?? null,
status: NEW_REGISTRATION_STATUS,
// The registrant's own referral_code is generated by the database (trg_assign_referral_code).
referred_by: referredBy,
@@ -367,6 +381,15 @@ export async function POST(req: NextRequest) {
);
}
+ // The pass is spent. If this fails the active registration still blocks a second submit.
+ if (payment.pass_id) {
+ const { error: passError } = await adminClient
+ .from('payment_passes')
+ .update({ used_at: new Date().toISOString() })
+ .eq('id', payment.pass_id);
+ if (passError) console.warn('Marking the payment pass used failed:', passError);
+ }
+
// Update profile with enrollment_no & phone
await adminClient
.from('profiles')
diff --git a/src/app/api/register/start-payment/route.ts b/src/app/api/register/start-payment/route.ts
new file mode 100644
index 0000000..19b1f3c
--- /dev/null
+++ b/src/app/api/register/start-payment/route.ts
@@ -0,0 +1,78 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getAuthenticatedUser } from '@/lib/auth-server';
+import { getSupabaseAdmin } from '@/lib/supabase';
+import { registrationsClosedResponse, registrationsOpen } from '@/lib/registration-gate';
+import { ATTEMPTS_USED_UP_ERROR, attemptsLeft } from '@/lib/rejections';
+import { REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats';
+import { createRateLimiter, readJsonObject } from '@/lib/security';
+
+export const runtime = 'nodejs';
+
+// One call each time a student leaves the details step, so this only stops abuse.
+const startLimiter = createRateLimiter({ limit: 30, windowMs: 10 * 60 * 1000 });
+
+/**
+ * POST /api/register/start-payment { fresh? }
+ * Called before the form shows any payment details: a live read of the database, never a cached count. Answers with what
+ * the student may do (issue_payment_pass, supabase/schema.sql section 20):
+ * { state: 'pay', amount, currentFee } show the payment details at this fee
+ * { state: 'already_paid', amount, currentFee } no payment details: continue only if already paid
+ * 409 { code: 'registrations_closed', error } no payment details at all
+ * The fee is locked here: the student may submit at it even if the phase closes or the fee rises meanwhile. `fresh` means
+ * the student hasn't paid and wants the current fee instead of an older one they were quoted.
+ */
+export async function POST(req: NextRequest) {
+ try {
+ const auth = await getAuthenticatedUser(req);
+ if (!auth || !auth.user.email) {
+ return NextResponse.json({ error: 'Please log in to continue.' }, { status: 401 });
+ }
+ const { user, token } = auth;
+ if (!registrationsOpen()) return registrationsClosedResponse();
+
+ if (!startLimiter.hit(user.id)) {
+ return NextResponse.json(
+ { error: 'Too many attempts. Please wait a few minutes and try again.' },
+ { status: 429, headers: { 'Retry-After': String(startLimiter.retryAfterSeconds(user.id)) } }
+ );
+ }
+
+ const body = await readJsonObject(req);
+ if (!body.ok) return NextResponse.json({ error: body.error }, { status: body.status });
+
+ // Nobody who can't submit should be shown a way to pay.
+ const client = getSupabaseAdmin(token);
+ const [active, rejected] = await Promise.all([
+ client.from('registrations').select('id').eq('user_id', user.id).neq('status', 'rejected').limit(1).maybeSingle(),
+ client.from('registrations').select('id', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'rejected'),
+ ]);
+ if (active.error || rejected.error) {
+ console.error('/api/register/start-payment registration lookup failed:', active.error || rejected.error);
+ return NextResponse.json({ error: 'Could not start the payment. Please try again.' }, { status: 500 });
+ }
+ if (active.data) {
+ return NextResponse.json({ error: 'You have already registered for Innovision 2026.' }, { status: 409 });
+ }
+ if (attemptsLeft(rejected.count ?? 0) === 0) {
+ return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 403 });
+ }
+
+ const { data, error } = await client.rpc('issue_payment_pass', { p_user_id: user.id, p_fresh: body.value.fresh === true });
+ if (error || !data) {
+ console.error('/api/register/start-payment pass failed:', error);
+ return NextResponse.json({ error: 'Could not start the payment. Please try again.' }, { status: 500 });
+ }
+
+ if (data.state === 'closed') {
+ return NextResponse.json({ code: 'registrations_closed', error: REGISTRATIONS_FULL_MESSAGE }, { status: 409 });
+ }
+ return NextResponse.json({
+ state: data.state,
+ amount: data.amount,
+ currentFee: data.current_fee ?? null,
+ });
+ } catch (err: unknown) {
+ console.error('API /api/register/start-payment error:', err);
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/seats/route.ts b/src/app/api/seats/route.ts
index 79e82e9..bd610eb 100644
--- a/src/app/api/seats/route.ts
+++ b/src/app/api/seats/route.ts
@@ -5,17 +5,17 @@ export const runtime = 'nodejs';
// Public, unauthenticated endpoint polled by every open registration form: serve from a short in-process cache and let
// the CDN cache it too, so a crowd watching the counter doesn't turn into one database query each. Short enough that
-// the number still visibly moves while someone is on the form. Excluded from the global no-store API header in
-// next.config.ts.
-const CACHE_TTL_MS = 10 * 1000;
-const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=10, s-maxage=15, stale-while-revalidate=30' };
+// the number still visibly moves while someone is on the form. Display only: POST /api/register/start-payment reads the
+// database live to decide who may pay. Excluded from the global no-store API header in next.config.ts.
+const CACHE_TTL_MS = 5 * 1000;
+const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=5, s-maxage=5, stale-while-revalidate=10' };
const NO_STORE = { 'Cache-Control': 'no-store' };
-let cached: { body: { total: number; left: number }; expires: number } | null = null;
+let cached: { body: { total: number; left: number; closed: boolean }; expires: number } | null = null;
/**
* GET /api/seats
- * The seat counter on the registration form: { total, left } (supabase/schema.sql, section 17). Only these two numbers,
- * nothing about any registration. 503 while the counter isn't set up: the form then hides it.
+ * The seat counter on the registration form: { total, left, closed } (supabase/schema.sql, sections 17 and 20). Only
+ * these, nothing about any registration. 503 while the counter isn't set up: the form then hides it.
*/
export async function GET() {
try {
@@ -28,7 +28,7 @@ export async function GET() {
const { data, error } = await getSupabaseAdmin()
.from('registration_counter')
- .select('total_seats, seats_left')
+ .select('total_seats, seats_left, closed_at')
.eq('id', 1)
.maybeSingle();
if (error || !data) {
@@ -36,7 +36,7 @@ export async function GET() {
return NextResponse.json({ error: 'Seat counter unavailable' }, { status: 503, headers: NO_STORE });
}
- const body = { total: data.total_seats, left: data.seats_left };
+ const body = { total: data.total_seats, left: data.seats_left, closed: data.closed_at !== null };
cached = { body, expires: Date.now() + CACHE_TTL_MS };
return NextResponse.json(body, { headers: CACHE_HEADERS });
} catch (err: unknown) {
diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx
index caaa429..e005538 100644
--- a/src/app/terms/page.tsx
+++ b/src/app/terms/page.tsx
@@ -1,6 +1,5 @@
import Link from 'next/link';
import LegalPage, { LegalContacts } from '@/components/legal/LegalPage';
-import { REGISTRATION_FEE } from '@/config/payment';
import { pageMetadata } from '@/lib/seo';
export const metadata = pageMetadata({
@@ -28,7 +27,11 @@ export default function TermsPage() {
Fee and payment
-
The registration fee is ₹{REGISTRATION_FEE}, paid by UPI to the account shown on the registration form.
+
+ The registration fee is the amount shown on the registration form when you start paying, paid by UPI to the
+ account shown there. It may be higher in later registration phases.
+
+
Registrations may close without notice. Don't pay while registrations are closed.
After paying, you upload a screenshot of the payment and enter its UPI transaction ID (UTR).
Your registration stays pending until the registration team has verified the payment. It is then
diff --git a/src/components/innovision/AdminDashboard.tsx b/src/components/innovision/AdminDashboard.tsx
index 0bf91a0..4091066 100644
--- a/src/components/innovision/AdminDashboard.tsx
+++ b/src/components/innovision/AdminDashboard.tsx
@@ -426,6 +426,8 @@ export default function AdminDashboard({
'Rejection Reason',
'Attempt',
'Amount (INR)',
+ 'Phase',
+ 'Allowed by Staff',
'UPI UTR',
'Payment Screenshot (private ref)',
'ID Card',
@@ -452,6 +454,8 @@ export default function AdminDashboard({
rejectionLabel(r.rejection_reason),
r.attempt ?? 1,
r.amount,
+ r.phase ?? '',
+ r.allowed_by ? 'Yes' : '',
r.utr,
r.payment_proof_path,
r.status === 'rejected'
@@ -1203,7 +1207,7 @@ export default function AdminDashboard({
{/* TAB 1: REGISTRATIONS */}
{activeTab === 'registrations' && (
-
+
{/* Stat Cards */}
)}
+ {reg.allowed_by && (
+
+ ALLOWED BY STAFF
+
+ )}
{/* The code this registrant entered: a campus ambassador's, or another registrant's. */}
{reg.referred_via && (
@@ -229,6 +236,29 @@ export default function AuthOverlay({ v }: { v: V }) {
Pay the fee
+ {/* The QR and UPI ID are only in the page while the server says this student may pay. */}
+ {!payOpen ? (
+
@@ -264,6 +294,7 @@ export default function AuthOverlay({ v }: { v: V }) {
{v.upi}
+ QR not working? Copy this UPI ID, then pay ₹{v.fee} to it from any UPI app (GPay, PhonePe, Paytm).
setPayQrOpen(false)}
style={{
diff --git a/src/components/innovision/Innovision.tsx b/src/components/innovision/Innovision.tsx
index f7bc65e..8e6b199 100644
--- a/src/components/innovision/Innovision.tsx
+++ b/src/components/innovision/Innovision.tsx
@@ -35,6 +35,7 @@ import { LiveV } from './SiteFooter';
import type { V } from './types';
import {
getSupabase,
+ startPayment,
isSupabaseConfigured,
getOrCreateUserProfile,
updateUserPhone,
@@ -55,9 +56,8 @@ import {
type GalleryPhoto,
} from '@/lib/supabase';
import { REGISTRATIONS_OPEN, REGISTRATIONS_SOON_PATH, MERCH_PATH, REGISTRATION_PREVIEW_ENABLED } from '@/config/launch';
-import { fetchSeatCount, SEATS_FULL_MESSAGE } from '@/lib/seats';
import { AMBASSADOR_FORM_URL, AMBASSADOR_SECTION } from '@/config/ambassador';
-import { REGISTRATION_FEE, UPI_ID, UPI_PAY_LINK, UPI_QR_SRC } from '@/config/payment';
+import { buildUpiPayLink, PREVIEW_REGISTRATION_FEE, UPI_ID, UPI_QR_SRC } from '@/config/payment';
import { SKIP_INTRO_KEY } from '@/components/coming-soon/SkipIntroOnReturn';
import { isValidGoogleDriveUrl, GENDER_OPTIONS as GENDERS, normalizeReferralCode, isReferralCode, formatReferralCode, REFERRAL_CODE_FORMAT_ERROR, isIterSoaCollege, isIterSoaEmail, ITER_SOA_ERROR_MESSAGE } from '@/lib/validation';
import { httpErrorMessage, isTransientFailure } from '@/lib/api-errors';
@@ -1729,16 +1729,36 @@ export class Innovision extends Component {
if (!this.alive || this.state.user?.id !== user.id) return;
this.syncRegistration(fresh);
if (!this.canRegisterAgain()) {
- this.toast('You can’t register again right now. Please contact the registration helpdesk.');
+ this.toast('You can’t register again right now. Please contact us.');
return;
}
}
this.dropFiles();
this.reg = null;
- this.setState({ files: {}, step: 0, err: {} }, () => {
+ this.setState({ files: {}, step: 0, err: {}, payOffer: null, payCheck: 'idle' }, () => {
if (this.state.auth) this.switchMode('register'); else this.openAuth('register');
});
};
+ /**
+ * The payment step asks the server again: the last check failed, or (`fresh`) a student who hadn't paid wants the
+ * current fee. The payment details stay hidden until it answers. If registrations have closed and there's nothing to
+ * continue with, back to the details step with the reason.
+ */
+ refreshPayOffer = async (fresh = false) => {
+ if (this.state.payCheck === 'busy') return;
+ if (REGISTRATION_PREVIEW_ENABLED && this.state.user?.id === 'dev-preview-user') return;
+ this.setState({ payCheck: 'busy' });
+ const pay = await startPayment(fresh);
+ if (!this.alive) return;
+ if (pay.offer) { this.setState({ payOffer: pay.offer, payCheck: 'idle' }); return; }
+ if (/closed/i.test(pay.error ?? '')) {
+ this.setState({ payOffer: null, payCheck: 'idle' });
+ this.toStep(0);
+ this.setState({ err: { form: pay.error } });
+ return;
+ }
+ this.setState({ payCheck: 'failed' });
+ };
/** MY PASS uploaded the college ID card: reload the registration so the pending marks go away everywhere. */
idCardUploaded = async () => {
const user = this.state.user;
@@ -2084,6 +2104,8 @@ export class Innovision extends Component {
const f = this.$ && this.$('[data-auth-form]');
if (f) f.reset();
this.setState({
+ payOffer: null,
+ payCheck: 'idle',
user: null,
registration: null,
adminOpen: false,
@@ -2111,7 +2133,8 @@ export class Innovision extends Component {
// @ts-ignore
newId() { return 'IV26-' + (Math.floor(Math.random() * 9000) + 1000); }
drop(err, k) { const e = { ...err }; delete e[k]; return e; }
- fee() { return REGISTRATION_FEE; }
+ /** The fee the server quoted this student (POST /api/register/start-payment). */
+ fee() { return this.state.payOffer?.amount ?? PREVIEW_REGISTRATION_FEE; }
// @ts-ignore
upi() { return UPI_ID; }
speed() { const n = Number(this.props.riftSpeed ?? 1); return n > 0 ? n : 1; }
@@ -2633,18 +2656,23 @@ export class Innovision extends Component {
if (this.fail(err)) return;
this.reg = { name, college, email, phone, enrollment_no, gender, referred_by_code };
- // Every student, NIT Rourkela included, continues to Step 1 (payment), unless every seat is taken: nobody should
- // pay for a seat that isn't there. A count that can't be read doesn't hold the student up.
- this.setState({ busyLbl: referred_by_code ? 'CHECKING CODE' : 'CHECKING SEATS' });
- Promise.all([fetchSeatCount(), referred_by_code ? checkReferralCode(referred_by_code) : null]).then(([seats, res]) => {
+ // Every student, NIT Rourkela included, continues to Step 1 (payment), but only with the server's answer, read live
+ // from the database: a payment can't be refunded, so payment details are never shown on a guess. No answer (closed,
+ // offline, server error) means no payment details.
+ const preview = REGISTRATION_PREVIEW_ENABLED && user.id === 'dev-preview-user';
+ const started = preview
+ ? Promise.resolve({ offer: { state: 'pay' as const, amount: PREVIEW_REGISTRATION_FEE, currentFee: PREVIEW_REGISTRATION_FEE } })
+ : startPayment();
+ this.setState({ busyLbl: referred_by_code ? 'CHECKING CODE' : 'CHECKING' });
+ Promise.all([started, referred_by_code ? checkReferralCode(referred_by_code) : null]).then(([pay, res]) => {
if (!this.alive) return;
this.setState({ busyLbl: '' });
// Moved on meanwhile (closed the form, signed out).
if (!this.state.auth || this.state.authMode !== 'register' || this.state.step !== 0) return;
- if (seats && seats.left === 0) { this.fail({ form: SEATS_FULL_MESSAGE }); return; }
// A check that couldn't run (offline, busy) doesn't hold the student up: the submit checks the code again.
if (res && 'valid' in res && !res.valid) { this.fail({ referred_by_code: res.error }); return; }
- this.toStep(1);
+ if (!pay.offer) { this.fail({ form: pay.error }); return; }
+ this.setState({ payOffer: pay.offer, payCheck: 'idle' }, () => this.toStep(1));
});
} else if (s.step === 1) {
this.toStep(2);
@@ -2948,7 +2976,7 @@ export class Innovision extends Component {
authTitle: reg ? 'Claim your seat' : am === 'login' ? 'Welcome back' : P.status === 'CONFIRMED' ? 'Your place in the odyssey' : P.status === 'REJECTED' ? 'Payment not approved' : 'Registration received',
authSub: reg
? 'Three short stops to register for Innovision 2026 at NIT Rourkela.'
- : am === 'login' ? 'Sign in or create your account with Google.' : P.status === 'CONFIRMED' ? 'Your payment is verified. Your INNO pass is always available here.' : P.status === 'REJECTED' ? (this.canRegisterAgain() ? 'See why below, then register again with the right details.' : 'Please contact the registration helpdesk for assistance.') : 'Your payment is under review by the registration team. Your pass appears here after approval.',
+ : am === 'login' ? 'Sign in or create your account with Google.' : P.status === 'CONFIRMED' ? 'Your payment is verified. Your INNO pass is always available here.' : P.status === 'REJECTED' ? (this.canRegisterAgain() ? 'See why below, then register again with the right details.' : 'Please contact us for assistance.') : 'Your payment is under review by the registration team. Your pass appears here after approval.',
// The seat counter (GET /api/seats) beside the registration form. Only while the overlay is open: it is mounted,
// closed, at idle (warmDeferred), and a mounted counter polls every 30 s. `auth` turns true before the overlay
// animates in and false after it has animated out, so the counter is never cut off mid-animation.
@@ -2971,7 +2999,8 @@ export class Innovision extends Component {
upPay: up('pay', 'payfile', 'Drop the screenshot here or browse (Max 1MB)'),
upId: up('id', 'idfile', 'Drop a photo of your ID card here or browse'),
fee, upi, copyUpi: this.copyUpi, copyLbl: s.copied ? 'COPIED' : 'COPY',
- upiLink: UPI_PAY_LINK, upiQr: UPI_QR_SRC,
+ upiLink: buildUpiPayLink(fee), upiQr: UPI_QR_SRC,
+ payOffer: s.payOffer ?? null, payCheck: s.payCheck ?? 'idle', refreshPayOffer: this.refreshPayOffer,
upiAppD: s.narrow ? 'inline-flex' : 'none',
authSubmit: this.authSubmit, clearErr: this.clearErr, switchMode: this.switchMode, closeAuthH: () => this.closeAuth(),
showSwitch: am !== 'pass' && !reg && !!s.user,
@@ -2988,7 +3017,7 @@ export class Innovision extends Component {
passNote: P.status === 'CONFIRMED'
? 'You are confirmed. Download your entry pass now and show the QR with your college ID at the registration desk, so entry at NIT Rourkela is quick.'
: P.status === 'REJECTED'
- ? 'Your registration could not be approved. Please contact the helpdesk.'
+ ? 'Your registration could not be approved. Please contact us.'
: 'Your payment is under review. Only the registration team can approve it. This page updates automatically while you are here.',
// The code shared from the pass: an ambassador's CA code, else the registrant's own (shareCode).
refCode: this.shareCode(), refCodeLabel: s.registration?.ambassador_code ? 'Your Ambassador Code:' : 'Your Referral Code:',
diff --git a/src/components/innovision/ProfileOverlay.tsx b/src/components/innovision/ProfileOverlay.tsx
index 722064f..9ab4b39 100644
--- a/src/components/innovision/ProfileOverlay.tsx
+++ b/src/components/innovision/ProfileOverlay.tsx
@@ -417,7 +417,7 @@ export default function ProfileOverlay({
? 'Your payment is being verified and your registration will be approved soon. Keep checking MY PASS: your entry QR will appear there once approved.'
: onRegisterAgain && (registration.attempts_left ?? 0) > 0
? 'Your registration could not be verified. See why below, then register again with the right details.'
- : 'Your registration could not be verified. Please visit the helpdesk or contact the event administrators.'}
+ : 'Your registration could not be verified. Please contact us.'}
{/* The college ID card (supabase/schema.sql section 19): uploaded from MY PASS. */}
diff --git a/src/components/innovision/SeatCounter.tsx b/src/components/innovision/SeatCounter.tsx
index 52fab3f..e6cc284 100644
--- a/src/components/innovision/SeatCounter.tsx
+++ b/src/components/innovision/SeatCounter.tsx
@@ -38,7 +38,7 @@ export default function SeatCounter() {
}, []);
if (!seats) return null;
- const full = seats.left === 0;
+ const full = seats.closed;
const few = !full && seats.left <= FEW_LEFT;
const share = seats.total > 0 ? seats.left / seats.total : 0;
@@ -46,11 +46,11 @@ export default function SeatCounter() {
// Centered, like the rest of the form's intro (AuthOverlay.module.css .intro).
- {full ? 'REGISTRATIONS FULL' : few ? 'FILLING FAST' : 'SEATS LEFT'}
+ {full ? 'REGISTRATIONS CLOSED' : few ? 'FILLING FAST' : 'SEATS LEFT'}
@@ -70,11 +70,6 @@ export default function SeatCounter() {
}}
/>
- {full ? (
-
- Every seat has been taken. Keep checking: a seat opens up whenever a registration is not approved.
-
- ) : null}
);
}
diff --git a/src/components/innovision/admin/SeatCounterCard.tsx b/src/components/innovision/admin/SeatCounterCard.tsx
index 81b3e6b..1446cc4 100644
--- a/src/components/innovision/admin/SeatCounterCard.tsx
+++ b/src/components/innovision/admin/SeatCounterCard.tsx
@@ -2,17 +2,21 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { httpErrorMessage } from '@/lib/api-errors';
-import { formatSeats, MAX_SEATS, seatCountError } from '@/lib/seats';
+import { formatSeats, MAX_FEE, MAX_SEATS, nextPhaseError, seatCountError } from '@/lib/seats';
/*
* SEAT COUNTER card on the admin Registrations tab (GET/PATCH /api/admin/seats). Total seats and seats left are what
* the registration form shows ("1,042 / 1,300 seats left"); after an edit the database keeps moving seats left by
- * itself (a registration takes a seat, a rejection gives it back). Admins edit; the registration team only sees it.
+ * itself. At 0 the phase closes, and only START NEXT PHASE (admins) reopens registrations, with a new fee and numbers.
+ * ALLOW A PAYMENT is the staff route for someone who paid while they couldn't submit. Admins edit; the registration
+ * team can allow a payment; viewers only see it.
*/
interface SeatCounterCardProps {
getAuthToken: () => Promise;
canEdit: boolean;
+ /** The registration team and admins. */
+ canAllowPayments: boolean;
/** Bumped by the dashboard after it reloads the list or reviews a registration: the count may have moved. */
refreshKey: number;
}
@@ -20,6 +24,9 @@ interface SeatCounterCardProps {
interface Counter {
total: number;
left: number;
+ phase: number;
+ fee: number;
+ closedAt: string | null;
updatedAt: string | null;
updatedBy: string | null;
}
@@ -35,7 +42,9 @@ const btn: React.CSSProperties = {
color: '#ECE8DF', fontSize: '11px', fontWeight: 700, letterSpacing: '.12em', cursor: 'pointer', whiteSpace: 'nowrap',
};
-export default function SeatCounterCard({ getAuthToken, canEdit, refreshKey }: SeatCounterCardProps) {
+const textInput: React.CSSProperties = { ...input, width: '220px' };
+
+export default function SeatCounterCard({ getAuthToken, canEdit, canAllowPayments, refreshKey }: SeatCounterCardProps) {
const [counter, setCounter] = useState(null);
const [error, setError] = useState('');
const [editing, setEditing] = useState(false);
@@ -43,6 +52,8 @@ export default function SeatCounterCard({ getAuthToken, canEdit, refreshKey }: S
const [left, setLeft] = useState('');
const [formError, setFormError] = useState('');
const [saving, setSaving] = useState(false);
+ const [phaseForm, setPhaseForm] = useState({ open: false, confirming: false, fee: '', total: '', left: '', error: '' });
+ const [allowForm, setAllowForm] = useState({ open: false, email: '', amount: '', note: '', error: '', done: '' });
// AdminDashboard recreates getAuthToken on every render; read it through a ref so loading isn't restarted each time.
const tokenRef = useRef(getAuthToken);
useEffect(() => {
@@ -95,10 +106,63 @@ export default function SeatCounterCard({ getAuthToken, canEdit, refreshKey }: S
}
};
+ const post = async (url: string, body: object) => {
+ const token = await tokenRef.current();
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
+ credentials: 'include',
+ body: JSON.stringify(body),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(httpErrorMessage(res) || data.error || 'The request failed');
+ };
+
+ const phaseNumbers = () => ({
+ fee: phaseForm.fee.trim() === '' ? NaN : Number(phaseForm.fee),
+ total: phaseForm.total.trim() === '' ? NaN : Number(phaseForm.total),
+ left: phaseForm.left.trim() === '' ? NaN : Number(phaseForm.left),
+ });
+
+ // Two steps: a new fee reaches students the moment it is saved, and can't be taken back without another phase.
+ const submitPhase = async (e: React.FormEvent) => {
+ e.preventDefault();
+ const { fee, total, left: seatsLeft } = phaseNumbers();
+ const invalid = nextPhaseError(fee, total, seatsLeft);
+ if (invalid) { setPhaseForm((f) => ({ ...f, error: invalid, confirming: false })); return; }
+ if (!phaseForm.confirming) { setPhaseForm((f) => ({ ...f, error: '', confirming: true })); return; }
+ try {
+ setSaving(true);
+ await post('/api/admin/seats/phase', { fee, total, left: seatsLeft });
+ setCounter(await request());
+ setPhaseForm({ open: false, confirming: false, fee: '', total: '', left: '', error: '' });
+ } catch (err: unknown) {
+ setPhaseForm((f) => ({ ...f, confirming: false, error: err instanceof Error ? err.message : 'Failed to start the next phase' }));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const submitAllow = async (e: React.FormEvent) => {
+ e.preventDefault();
+ try {
+ setSaving(true);
+ const email = allowForm.email.trim();
+ await post('/api/admin/registrations/payment-pass', { email, amount: Number(allowForm.amount), note: allowForm.note });
+ setAllowForm({ open: true, email: '', amount: '', note: '', error: '', done: `${email} can now sign in and submit their proof, for 7 days.` });
+ } catch (err: unknown) {
+ setAllowForm((f) => ({ ...f, done: '', error: err instanceof Error ? err.message : 'Failed to allow the payment' }));
+ } finally {
+ setSaving(false);
+ }
+ };
+
const taken = counter ? counter.total - counter.left : 0;
const share = counter && counter.total > 0 ? counter.left / counter.total : 0;
+ const closed = !!counter?.closedAt;
return (
+
- {formatSeats(taken)} taken. A new registration takes a seat, a rejection gives it back, approving changes nothing.
- {counter.left === 0 ? ' The form is stopping new registrations before payment.' : ''}
+ {formatSeats(taken)} taken. While open, a new registration takes a seat, a rejection gives it back, approving changes nothing. At 0 registrations close.
{counter.updatedAt && counter.updatedBy
? ` Last set by ${counter.updatedBy} on ${new Date(counter.updatedAt).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' })}.`
: ''}
+
+ PHASE {counter.phase} · ₹{formatSeats(counter.fee)} · {closed ? 'CLOSED' : 'OPEN'}
+
>
)}
- {canEdit && counter && !editing ? (
+ {/* While closed, the only thing to do with the numbers is START NEXT PHASE: an edit wouldn't reopen anything. */}
+ {canEdit && counter && !editing && !closed ? (
) : null}
@@ -159,5 +226,73 @@ export default function SeatCounterCard({ getAuthToken, canEdit, refreshKey }: S
) : null}
+
+ {canEdit && counter && closed ? (
+
+ {!phaseForm.open ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+
+ {canAllowPayments && counter ? (
+
+ {!allowForm.open ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+
);
}
diff --git a/src/config/payment.ts b/src/config/payment.ts
index a4db890..c798a46 100644
--- a/src/config/payment.ts
+++ b/src/config/payment.ts
@@ -1,20 +1,14 @@
/**
* Registration payment details shown on the "Pay the fee" step of the registration form.
*
- * The server never reads these: POST /api/register always records REGISTRATION_FEE_INR from src/lib/security.ts.
- * Keep REGISTRATION_FEE in step with it (a unit test checks they match).
+ * The server never reads these. The fee isn't here: it belongs to the registration phase and the student's payment pass
+ * in the database (supabase/schema.sql section 20), and reaches the form through POST /api/register/start-payment.
*
- * public/assets/upi-qr.svg is currently a non-scannable "PAYMENT QR / COMING SOON" placeholder; the organisers will
- * supply the real QR (and its UPI ID and payee name, which must replace the values below) before registrations open.
- *
- * The real QR must encode UPI_PAY_LINK. If the UPI ID, payee name or fee changes, regenerate it from the new
- * link as ONE byte-mode segment, error correction M, e.g. with the `qrcode` package:
- * QRCode.toString([{ data: UPI_PAY_LINK, mode: 'byte' }], { type: 'svg', errorCorrectionLevel: 'M', margin: 1,
- * color: { dark: '#0c0b0aff', light: '#ECE8DFff' } })
- * Left to pick its own segments, `qrcode` splits the link into byte + numeric + byte around the fee, and that
- * symbol fails to decode in ZXing (the reader behind many Android apps). Check it scans to the new link.
+ * The QR (UPI_QR_SRC) is the SBI merchant QR for this account. It encodes the payee with an empty amount (`am=`), so it
+ * works at any fee, but the student types the amount in themselves: scan it to check before replacing it with another.
*/
-export const REGISTRATION_FEE = 999;
+/** Only for the review-only registration preview (REGISTRATION_PREVIEW_ENABLED), which never talks to the server. */
+export const PREVIEW_REGISTRATION_FEE = 999;
export const UPI_ID = '85002429435@sbi';
export const UPI_PAYEE_NAME = 'PLAN FUND NIT ROURKELA';
export const UPI_NOTE = 'Innovision 2026 registration';
@@ -23,10 +17,10 @@ export const UPI_NOTE = 'Innovision 2026 registration';
* `upi://pay` deep link: opens the student's UPI app with the payee, amount and note filled in. The VPA stays
* unencoded (its characters are URL-safe, and some UPI apps reject `%40` in place of `@`).
*/
-export const UPI_PAY_LINK =
+export const buildUpiPayLink = (amount: number) =>
'upi://pay?pa=' + UPI_ID +
'&pn=' + encodeURIComponent(UPI_PAYEE_NAME) +
- '&am=' + REGISTRATION_FEE +
+ '&am=' + amount +
'&cu=INR&tn=' + encodeURIComponent(UPI_NOTE);
/** Official SBI QR code standee in webp format. */
diff --git a/src/lib/pass-experience.ts b/src/lib/pass-experience.ts
index 9ac4588..8c2aee8 100644
--- a/src/lib/pass-experience.ts
+++ b/src/lib/pass-experience.ts
@@ -28,8 +28,8 @@ export function isApprovedPass(pass: ParticipantPass): boolean {
export function passStatusMessage(pass: ParticipantPass): string {
if (pass.registrationStatus === 'pending') return 'Payment under review. The registration team will verify your payment before issuing your pass.';
- if (pass.registrationStatus === 'rejected') return 'Your payment was not approved. Please contact the registration helpdesk.';
- if (pass.passStatus === 'revoked') return 'Your pass has been cancelled. Please contact the registration helpdesk.';
+ if (pass.registrationStatus === 'rejected') return 'Your payment was not approved. Please contact us.';
+ if (pass.passStatus === 'revoked') return 'Your pass has been cancelled. Please contact us.';
return 'Your pass is not available yet. Complete your registration and wait for the registration team to verify your payment.';
}
diff --git a/src/lib/rejections.ts b/src/lib/rejections.ts
index 99764ce..860a421 100644
--- a/src/lib/rejections.ts
+++ b/src/lib/rejections.ts
@@ -24,7 +24,7 @@ export const REJECTION_REASONS = [
{
value: 'wrong_amount',
label: 'Wrong amount',
- message: 'The amount paid was not the registration fee. Pay the exact fee shown on the payment step.',
+ message: "The amount paid was not the registration fee. Don't pay the full fee again: contact us with your UTR.",
},
{
value: 'duplicate_payment',
@@ -55,7 +55,7 @@ export function attemptsLeft(rejected: number): number {
}
export const ATTEMPTS_USED_UP_ERROR =
- `This account has been rejected ${MAX_REGISTRATION_ATTEMPTS} times and can't register again. Please contact the registration helpdesk.`;
+ `This account has been rejected ${MAX_REGISTRATION_ATTEMPTS} times and can't register again. Please contact us.`;
/** The database refused a registration because the account has no attempts left (trg_guard_registration_attempts). */
export function isAttemptsUsedUpError(err: { message?: string } | null | undefined): boolean {
diff --git a/src/lib/seats.ts b/src/lib/seats.ts
index e379ec1..98cd486 100644
--- a/src/lib/seats.ts
+++ b/src/lib/seats.ts
@@ -1,30 +1,37 @@
/**
- * The seat counter on the registration form (supabase/schema.sql, section 17). Client & server safe, and free of `@/`
- * imports so it can be unit tested directly with `node --test`.
+ * The seat counter on the registration form and the registration phases (supabase/schema.sql, sections 17 and 20).
+ * Client & server safe, and free of `@/` imports so it can be unit tested directly with `node --test`.
*
- * Admins set both numbers in the admin dashboard; after that the database moves `left` by itself (a registration
- * takes a seat, a rejection gives it back). The form stops before the payment step once `left` is 0.
+ * Admins set both numbers in the admin dashboard; after that the database moves `left` by itself. When `left` reaches 0
+ * the phase closes, and stays closed until an admin starts the next phase with a new fee and new numbers.
*/
/** Largest total an admin may set (matches registration_counter_seats_check). */
export const MAX_SEATS = 10000;
+/** Largest fee an admin may set, in rupees (matches registration_counter_phase_check). */
+export const MAX_FEE = 100000;
+
export interface SeatCount {
total: number;
left: number;
+ closed: boolean;
}
-export const SEATS_FULL_MESSAGE =
- 'Registrations are full: every seat has been taken. Nothing was charged; please keep checking this page in case seats open up.';
+/**
+ * Must not say "nothing was charged": a student seeing this may have paid with a saved QR or the UPI ID.
+ * "Contact us" is the footer section that has the numbers.
+ */
+export const REGISTRATIONS_FULL_MESSAGE = "Registrations are closed. Already paid? Don't pay again: contact us with your UTR.";
const isSeatNumber = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 0 && (n as number) <= MAX_SEATS;
-/** The counter from a JSON body ({ total, left }), or null if it isn't a valid one. */
+/** The counter from a JSON body ({ total, left, closed }), or null if it isn't a valid one. */
export function parseSeatCount(value: unknown): SeatCount | null {
if (!value || typeof value !== 'object') return null;
- const { total, left } = value as Record;
+ const { total, left, closed } = value as Record;
if (!isSeatNumber(total) || !isSeatNumber(left) || left > total) return null;
- return { total, left };
+ return { total, left, closed: closed === true || left === 0 };
}
/** Why an admin's new numbers can't be saved, or null if they can. */
@@ -35,6 +42,17 @@ export function seatCountError(total: unknown, left: unknown): string | null {
return null;
}
+export const isFee = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= MAX_FEE;
+
+/** Why an admin's next phase can't be started, or null if it can. */
+export function nextPhaseError(fee: unknown, total: unknown, left: unknown): string | null {
+ if (!isFee(fee)) return `The fee must be a whole number of rupees from 1 to ${MAX_FEE}.`;
+ const invalid = seatCountError(total, left);
+ if (invalid) return invalid;
+ if (left === 0) return 'A new phase needs at least one seat left.';
+ return null;
+}
+
/** 1300 → "1,300" (Indian digit grouping, the same as the rest of the site). */
export function formatSeats(n: number): string {
return n.toLocaleString('en-IN');
@@ -42,7 +60,8 @@ export function formatSeats(n: number): string {
/**
* The public count (GET /api/seats), or null when it couldn't be read (offline, server error, or the counter isn't
- * set up yet): the form then hides the counter and lets the student carry on.
+ * set up yet): the form then hides the counter. For display only: POST /api/register/start-payment is what decides
+ * whether a student may pay.
*/
export async function fetchSeatCount(): Promise {
try {
@@ -53,3 +72,20 @@ export async function fetchSeatCount(): Promise {
return null;
}
}
+
+/** What the form may show on the payment step (POST /api/register/start-payment). */
+export interface PaymentOffer {
+ /** 'pay': show the payment details. 'already_paid': no payment details, only offer to continue with the proof. */
+ state: 'pay' | 'already_paid';
+ amount: number;
+ /** The fee of the phase open now, if there is one: an 'already_paid' student who hasn't paid can pay this instead. */
+ currentFee: number | null;
+}
+
+/** The offer from a POST /api/register/start-payment response, or null if it isn't a valid one. */
+export function parsePaymentOffer(value: unknown): PaymentOffer | null {
+ if (!value || typeof value !== 'object') return null;
+ const { state, amount, currentFee } = value as Record;
+ if ((state !== 'pay' && state !== 'already_paid') || !isFee(amount)) return null;
+ return { state, amount, currentFee: isFee(currentFee) ? currentFee : null };
+}
diff --git a/src/lib/security.ts b/src/lib/security.ts
index 77a3cba..98edbbf 100644
--- a/src/lib/security.ts
+++ b/src/lib/security.ts
@@ -329,10 +329,10 @@ const optionalString = (v: unknown): string | null | undefined =>
v === undefined || v === null ? null : typeof v === 'string' ? v : undefined;
/**
- * The one registration fee (INR) and the status every new registration starts in. There is no free or auto-confirmed
- * tier: every registrant, whatever their email domain, pays and is reviewed by staff. Never read from the request.
+ * The status every new registration starts in. There is no free or auto-confirmed tier: every registrant, whatever
+ * their email domain, pays and is reviewed by staff. The fee comes from the database (the current phase, or the student's
+ * payment pass: supabase/schema.sql section 20), never from the request.
*/
-export const REGISTRATION_FEE_INR = 999;
export const NEW_REGISTRATION_STATUS = 'pending';
export const NIT_ROURKELA_COLLEGE = 'National Institute of Technology Rourkela';
diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts
index cb0232f..21e518d 100644
--- a/src/lib/supabase.ts
+++ b/src/lib/supabase.ts
@@ -1,6 +1,7 @@
import { createClient, SupabaseClient, Session, User } from '@supabase/supabase-js';
import { httpErrorMessage } from './api-errors';
import type { RejectionReason } from './rejections';
+import { parsePaymentOffer, type PaymentOffer } from './seats';
import { SITE_URL } from '@/config/site';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
@@ -60,6 +61,10 @@ export interface Registration {
referred_via?: { kind: 'registrant' | 'ambassador'; code: string; name: string } | null;
/** Why staff rejected it (src/lib/rejections.ts); null for other statuses and for rejections made before reasons. */
rejection_reason?: RejectionReason | null;
+ /** The registration phase it was charged at (supabase/schema.sql section 20). */
+ phase?: number;
+ /** The staff member who allowed it (ALLOW A PAYMENT), if any. */
+ allowed_by?: string | null;
/** Not a column: added by GET /api/register to a rejected registration, the registrations the user may still make. */
attempts_left?: number;
/** Not a column: added by GET /api/admin/registrations, 1 for a first registration, 2 after one rejection, ... */
@@ -655,6 +660,29 @@ export async function fetchUserRegistrationServer(userId: string, email?: string
}
}
+/**
+ * Asks the server what this student may do on the payment step (POST /api/register/start-payment). Without an `offer`
+ * the form must not show payment details: `error` says why, and it applies to a failed request too.
+ */
+export async function startPayment(fresh = false): Promise<{ offer?: PaymentOffer; error?: string }> {
+ const failed = 'Could not check registrations. Please check your connection and try again.';
+ try {
+ const { data: { session } } = await getSupabase().auth.getSession();
+ const token = session?.access_token;
+ const res = await fetch('/api/register/start-payment', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
+ body: JSON.stringify({ fresh }),
+ });
+ const result = await res.json().catch(() => ({}));
+ if (!res.ok) return { error: httpErrorMessage(res) || result.error || failed };
+ const offer = parsePaymentOffer(result);
+ return offer ? { offer } : { error: failed };
+ } catch {
+ return { error: failed };
+ }
+}
+
/**
* Create a new registration
* Passes through /api/register which is protected by server auth middleware
diff --git a/supabase/manual/registration_phases.sql b/supabase/manual/registration_phases.sql
new file mode 100644
index 0000000..bb5c632
--- /dev/null
+++ b/supabase/manual/registration_phases.sql
@@ -0,0 +1,348 @@
+-- ==============================================================================
+-- MIGRATION (run once in the Supabase SQL editor): registration phases.
+--
+-- Why: when the seat counter reaches 0 registrations close, and admins later start the next phase by hand with a new
+-- fee and new numbers. A UPI payment can't be stopped or refunded, so the server remembers the fee it quoted each
+-- student and honours it, and everyone else goes through the helpdesk.
+-- This adds section 20 of supabase/schema.sql. Existing registrations become phase 1 and keep their amount. The counter
+-- keeps its current numbers: phase 1 at Rs 999, closed straight away only if seats left is already 0.
+--
+-- Run this BEFORE deploying the code that uses it: the new form refuses to show payment details without it.
+-- After this, do NOT re-run seat_counter_and_reregistration.sql: it would put back the old counter trigger (re-running
+-- this file repairs that).
+-- Kept out of supabase/migrations/ on purpose so the Supabase CLI never applies it automatically. Safe to re-run: it
+-- never changes the phase, fee or numbers once they exist.
+-- ==============================================================================
+
+-- 20. REGISTRATION PHASES
+-- Registrations sell in phases, one at a time. registration_counter holds the current phase and its fee. A phase is
+-- open until closed_at is set, which happens by itself the moment seats_left reaches 0, however it got there. Only
+-- start_registration_phase() (an admin, POST /api/admin/seats/phase) opens the next one: editing the numbers never does.
+ALTER TABLE public.registration_counter ADD COLUMN IF NOT EXISTS phase INTEGER NOT NULL DEFAULT 1;
+ALTER TABLE public.registration_counter ADD COLUMN IF NOT EXISTS fee_inr INTEGER NOT NULL DEFAULT 999;
+ALTER TABLE public.registration_counter ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;
+
+-- phase: the phase whose fee it was charged at. submitted_late: submitted after its phase closed and after the time
+-- allowed for paying, so staff must check the payment date against pay_by on the bank statement. allowed_by: the staff
+-- member who allowed it through the helpdesk. amount: the full fee owed. amount_due: the part still to be paid (asked
+-- for by staff when someone paid too little: there are no refunds, so they pay the difference rather than the whole
+-- fee again), and top_up_utr is the UTR of that second payment.
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS phase INTEGER NOT NULL DEFAULT 1;
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS submitted_late BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS pay_by TIMESTAMPTZ;
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS allowed_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL;
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS amount_due INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS top_up_utr TEXT;
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS top_up_recorded_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL;
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS top_up_recorded_at TIMESTAMPTZ;
+
+DO $$
+BEGIN
+ -- 100000 matches MAX_FEE in src/lib/seats.ts.
+ ALTER TABLE public.registration_counter DROP CONSTRAINT IF EXISTS registration_counter_phase_check;
+ ALTER TABLE public.registration_counter ADD CONSTRAINT registration_counter_phase_check
+ CHECK (phase >= 1 AND fee_inr BETWEEN 1 AND 100000);
+ ALTER TABLE public.registrations DROP CONSTRAINT IF EXISTS registrations_amount_due_check;
+ ALTER TABLE public.registrations ADD CONSTRAINT registrations_amount_due_check
+ CHECK (amount_due BETWEEN 0 AND 100000);
+ ALTER TABLE public.registrations DROP CONSTRAINT IF EXISTS registrations_top_up_utr_check;
+ ALTER TABLE public.registrations ADD CONSTRAINT registrations_top_up_utr_check
+ CHECK (top_up_utr IS NULL OR top_up_utr ~ '^[0-9]{12}$');
+END $$;
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_registrations_top_up_utr ON public.registrations(top_up_utr)
+ WHERE top_up_utr IS NOT NULL;
+
+-- Closes the phase the moment the count reaches 0: in the same statement, so simultaneous registrations can't slip past.
+CREATE OR REPLACE FUNCTION public.close_registration_phase_at_zero() RETURNS TRIGGER AS $$
+BEGIN
+ IF NEW.seats_left = 0 AND NEW.closed_at IS NULL THEN
+ NEW.closed_at := now();
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SET search_path = public;
+
+DROP TRIGGER IF EXISTS trg_close_registration_phase_at_zero ON public.registration_counter;
+CREATE TRIGGER trg_close_registration_phase_at_zero
+ BEFORE INSERT OR UPDATE OF seats_left ON public.registration_counter
+ FOR EACH ROW EXECUTE FUNCTION public.close_registration_phase_at_zero();
+
+UPDATE public.registration_counter SET closed_at = now() WHERE seats_left = 0 AND closed_at IS NULL;
+
+-- Replaces section 17's version. The count only moves for a registration of the current phase while that phase is open:
+-- a rejection after closing can't reopen sales, and a late or older-phase registration doesn't touch the new phase's
+-- count. The WHERE clause does the check, so it holds even if a phase closes at the same moment.
+CREATE OR REPLACE FUNCTION public.move_registration_counter() RETURNS TRIGGER AS $$
+DECLARE
+ v_held BOOLEAN := false;
+ v_holds BOOLEAN := false;
+ v_phase INTEGER;
+BEGIN
+ IF TG_OP <> 'INSERT' THEN
+ v_held := COALESCE(OLD.status IN ('pending', 'confirmed'), false);
+ v_phase := OLD.phase;
+ END IF;
+ IF TG_OP <> 'DELETE' THEN
+ v_holds := COALESCE(NEW.status IN ('pending', 'confirmed'), false);
+ v_phase := NEW.phase;
+ END IF;
+ IF v_holds AND NOT v_held THEN
+ UPDATE public.registration_counter SET seats_left = GREATEST(seats_left - 1, 0)
+ WHERE id = 1 AND phase = v_phase AND closed_at IS NULL;
+ ELSIF v_held AND NOT v_holds THEN
+ UPDATE public.registration_counter SET seats_left = LEAST(seats_left + 1, total_seats)
+ WHERE id = 1 AND phase = v_phase AND closed_at IS NULL;
+ END IF;
+ RETURN NULL;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
+
+-- Replaces section 5's version: the fee is the current phase's, and the phase columns can't be chosen by the inserter.
+CREATE OR REPLACE FUNCTION public.enforce_registration_insert() RETURNS TRIGGER AS $$
+DECLARE
+ v_email TEXT;
+ v_confirmed TIMESTAMPTZ;
+BEGIN
+ IF COALESCE(auth.role(), '') <> 'authenticated' THEN
+ RETURN NEW;
+ END IF;
+
+ SELECT lower(email), email_confirmed_at INTO v_email, v_confirmed FROM auth.users WHERE id = auth.uid();
+
+ NEW.user_id := auth.uid();
+ NEW.email := COALESCE(v_email, '');
+ NEW.registration_id := 'IV26-' || (1000 + (('x' || substr(md5(gen_random_uuid()::text), 1, 7))::bit(28)::int % 9000))::text;
+ NEW.reviewed_by := NULL;
+ NEW.reviewed_at := NULL;
+ NEW.student_type := CASE WHEN v_email LIKE '%@nitrkl.ac.in' AND v_confirmed IS NOT NULL THEN 'internal' ELSE 'external' END;
+ NEW.status := 'pending';
+ SELECT fee_inr, phase INTO NEW.amount, NEW.phase FROM public.registration_counter WHERE id = 1;
+ NEW.submitted_late := false;
+ NEW.pay_by := NULL;
+ NEW.allowed_by := NULL;
+ NEW.amount_due := 0;
+ NEW.top_up_utr := NULL;
+ NEW.top_up_recorded_by := NULL;
+ NEW.top_up_recorded_at := NULL;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
+
+-- Refuses approval while part of the fee is still due, whoever updates: the service role included.
+-- /api/admin/registrations looks for this message.
+CREATE OR REPLACE FUNCTION public.require_amount_paid_for_approval() RETURNS TRIGGER AS $$
+BEGIN
+ IF NEW.status = 'confirmed' AND OLD.status IS DISTINCT FROM 'confirmed' AND NEW.amount_due > 0 THEN
+ RAISE EXCEPTION 'Amount due before approval' USING ERRCODE = '23514';
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SET search_path = public;
+
+DROP TRIGGER IF EXISTS trg_require_amount_paid_for_approval ON public.registrations;
+CREATE TRIGGER trg_require_amount_paid_for_approval
+ BEFORE UPDATE OF status ON public.registrations
+ FOR EACH ROW EXECUTE FUNCTION public.require_amount_paid_for_approval();
+
+-- One UPI transaction pays for one thing: a top-up's UTR can't be any registration's UTR, in either direction. The same
+-- code and wording as uq_registrations_utr, so /api/register answers it the same way.
+CREATE OR REPLACE FUNCTION public.guard_top_up_utr() RETURNS TRIGGER AS $$
+BEGIN
+ IF NEW.top_up_utr IS NOT NULL AND (
+ NEW.top_up_utr = NEW.utr
+ OR EXISTS (SELECT 1 FROM public.registrations r WHERE r.utr = NEW.top_up_utr AND r.status <> 'rejected' AND r.id <> NEW.id)
+ ) THEN
+ RAISE EXCEPTION 'This utr is already used by a registration' USING ERRCODE = '23505';
+ END IF;
+ IF NEW.utr IS NOT NULL
+ AND EXISTS (SELECT 1 FROM public.registrations r WHERE r.top_up_utr = NEW.utr AND r.id <> NEW.id) THEN
+ RAISE EXCEPTION 'This utr is already used by a top-up' USING ERRCODE = '23505';
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SET search_path = public;
+
+DROP TRIGGER IF EXISTS trg_guard_top_up_utr ON public.registrations;
+CREATE TRIGGER trg_guard_top_up_utr
+ BEFORE INSERT OR UPDATE OF utr, top_up_utr ON public.registrations
+ FOR EACH ROW EXECUTE FUNCTION public.guard_top_up_utr();
+
+-- A payment pass is the server's record of the fee quoted to a student. The form shows payment details only with a pass
+-- and until pay_by; the student may submit at that fee until submit_by, even after the phase closes or the fee rises.
+-- issued_by is set when staff allowed a payment through the helpdesk (POST /api/admin/registrations/payment-pass); note
+-- says why. used_at is set once it is turned into a registration, or replaced by a newer pass. Only the server reads
+-- and writes these.
+CREATE TABLE IF NOT EXISTS public.payment_passes (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
+ phase INTEGER NOT NULL,
+ amount INTEGER NOT NULL CHECK (amount BETWEEN 1 AND 100000),
+ pay_by TIMESTAMPTZ NOT NULL,
+ submit_by TIMESTAMPTZ NOT NULL,
+ issued_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
+ note TEXT CHECK (note IS NULL OR char_length(note) <= 300),
+ used_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_payment_passes_user ON public.payment_passes(user_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_payment_passes_in_progress ON public.payment_passes(pay_by) WHERE used_at IS NULL;
+
+ALTER TABLE public.payment_passes ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON public.payment_passes FROM PUBLIC, anon, authenticated;
+GRANT ALL ON public.payment_passes TO service_role;
+
+-- The registration a student may re-register from without paying again: their latest, rejected for a reason that doesn't
+-- question the payment itself, at a fee that isn't the current open one (otherwise they just get the current fee).
+CREATE OR REPLACE FUNCTION public.inherited_registration_payment(p_user_id UUID, p_counter public.registration_counter)
+RETURNS public.registrations AS $$
+DECLARE
+ r public.registrations%ROWTYPE;
+BEGIN
+ SELECT * INTO r FROM public.registrations WHERE user_id = p_user_id ORDER BY created_at DESC LIMIT 1;
+ IF FOUND AND r.status = 'rejected' AND r.rejection_reason IN ('unclear_screenshot', 'utr_mismatch') AND r.amount > 0
+ AND (p_counter.closed_at IS NOT NULL OR r.phase <> p_counter.phase) THEN
+ RETURN r;
+ END IF;
+ RETURN NULL;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
+
+-- POST /api/register/start-payment. Answers:
+-- state 'pay': a pass, show the payment details until pay_by.
+-- state 'already_paid': a pass, but the time to pay has passed: no payment details, only "already paid? continue".
+-- current_fee is set when a phase is open, so the student can pay that instead.
+-- state 'closed': no pass and no phase open.
+-- p_fresh: the student hasn't paid and wants the current fee, replacing any older pass. Ignored while closed.
+-- FOR SHARE orders this against a registration closing the phase: a pass issued a moment before closing stands.
+CREATE OR REPLACE FUNCTION public.issue_payment_pass(p_user_id UUID, p_fresh BOOLEAN DEFAULT false)
+RETURNS JSONB AS $$
+DECLARE
+ c public.registration_counter%ROWTYPE;
+ p public.payment_passes%ROWTYPE;
+ r public.registrations%ROWTYPE;
+ v_open BOOLEAN;
+ v_state TEXT := 'pay';
+BEGIN
+ SELECT * INTO c FROM public.registration_counter WHERE id = 1 FOR SHARE;
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'registration_counter has no row';
+ END IF;
+ v_open := c.closed_at IS NULL;
+
+ IF NOT (p_fresh AND v_open) THEN
+ SELECT * INTO p FROM public.payment_passes
+ WHERE user_id = p_user_id AND used_at IS NULL AND submit_by > now()
+ ORDER BY created_at DESC LIMIT 1;
+ IF FOUND THEN
+ IF p.pay_by <= now() THEN
+ v_state := 'already_paid';
+ END IF;
+ -- The student's own pass in the phase still open simply renews below, at the same fee.
+ IF p.pay_by > now() OR p.issued_by IS NOT NULL OR NOT v_open OR p.phase <> c.phase THEN
+ RETURN jsonb_build_object(
+ 'state', v_state, 'amount', p.amount, 'phase', p.phase,
+ 'pay_seconds', GREATEST(EXTRACT(EPOCH FROM p.pay_by - now()), 0)::INT,
+ 'current_fee', CASE WHEN v_open THEN c.fee_inr END);
+ END IF;
+ ELSE
+ r := public.inherited_registration_payment(p_user_id, c);
+ IF r.id IS NOT NULL THEN
+ INSERT INTO public.payment_passes (user_id, phase, amount, pay_by, submit_by)
+ VALUES (p_user_id, r.phase, r.amount::INT, now(), now() + interval '24 hours')
+ RETURNING * INTO p;
+ RETURN jsonb_build_object(
+ 'state', 'already_paid', 'amount', p.amount, 'phase', p.phase, 'pay_seconds', 0,
+ 'current_fee', CASE WHEN v_open THEN c.fee_inr END);
+ END IF;
+ END IF;
+ END IF;
+
+ IF NOT v_open THEN
+ RETURN jsonb_build_object('state', 'closed');
+ END IF;
+
+ UPDATE public.payment_passes SET used_at = now() WHERE user_id = p_user_id AND used_at IS NULL;
+ -- 15 minutes to pay, 24 hours to submit. After that: the current fee, or the helpdesk.
+ INSERT INTO public.payment_passes (user_id, phase, amount, pay_by, submit_by)
+ VALUES (p_user_id, c.phase, c.fee_inr, now() + interval '15 minutes', now() + interval '24 hours')
+ RETURNING * INTO p;
+ RETURN jsonb_build_object(
+ 'state', 'pay', 'amount', p.amount, 'phase', p.phase,
+ 'pay_seconds', GREATEST(EXTRACT(EPOCH FROM p.pay_by - now()), 0)::INT,
+ 'current_fee', c.fee_inr);
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
+
+-- POST /api/register: what this submit is charged and whether it may go ahead at all. Never reads the request: a pass
+-- first, then a payment re-submitted after a rejection, then the current fee if a phase is open. Otherwise refused.
+CREATE OR REPLACE FUNCTION public.resolve_registration_payment(p_user_id UUID)
+RETURNS JSONB AS $$
+DECLARE
+ c public.registration_counter%ROWTYPE;
+ p public.payment_passes%ROWTYPE;
+ r public.registrations%ROWTYPE;
+BEGIN
+ SELECT * INTO c FROM public.registration_counter WHERE id = 1 FOR SHARE;
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'registration_counter has no row';
+ END IF;
+
+ SELECT * INTO p FROM public.payment_passes
+ WHERE user_id = p_user_id AND used_at IS NULL AND submit_by > now()
+ ORDER BY created_at DESC LIMIT 1;
+ IF FOUND THEN
+ RETURN jsonb_build_object(
+ 'allowed', true, 'amount', p.amount, 'phase', p.phase, 'pass_id', p.id, 'pay_by', p.pay_by,
+ 'allowed_by', p.issued_by,
+ 'late', (c.closed_at IS NOT NULL OR p.phase <> c.phase) AND now() > p.pay_by);
+ END IF;
+
+ r := public.inherited_registration_payment(p_user_id, c);
+ IF r.id IS NOT NULL THEN
+ RETURN jsonb_build_object('allowed', true, 'amount', r.amount, 'phase', r.phase, 'late', true);
+ END IF;
+
+ IF c.closed_at IS NULL THEN
+ RETURN jsonb_build_object('allowed', true, 'amount', c.fee_inr, 'phase', c.phase, 'late', false);
+ END IF;
+ RETURN jsonb_build_object('allowed', false);
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
+
+-- POST /api/admin/seats/phase. The only way to reopen. Refused while a phase is open, so a double click or a stale
+-- screen can't skip a phase or change the fee under students who are paying.
+CREATE OR REPLACE FUNCTION public.start_registration_phase(p_fee INTEGER, p_total INTEGER, p_left INTEGER, p_admin UUID)
+RETURNS VOID AS $$
+DECLARE
+ c public.registration_counter%ROWTYPE;
+BEGIN
+ SELECT * INTO c FROM public.registration_counter WHERE id = 1 FOR UPDATE;
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'registration_counter has no row';
+ END IF;
+ IF c.closed_at IS NULL THEN
+ RAISE EXCEPTION 'Phase % is still open', c.phase USING ERRCODE = '55000';
+ END IF;
+ IF p_left < 1 THEN
+ RAISE EXCEPTION 'A new phase needs at least one seat' USING ERRCODE = '23514';
+ END IF;
+ UPDATE public.registration_counter
+ SET phase = c.phase + 1, fee_inr = p_fee, total_seats = p_total, seats_left = p_left, closed_at = NULL,
+ updated_by = p_admin, updated_at = now()
+ WHERE id = 1;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
+
+-- Only the server may call these; the triggers need no grant.
+REVOKE EXECUTE ON FUNCTION public.close_registration_phase_at_zero() FROM PUBLIC, anon, authenticated, service_role;
+REVOKE EXECUTE ON FUNCTION public.require_amount_paid_for_approval() FROM PUBLIC, anon, authenticated, service_role;
+REVOKE EXECUTE ON FUNCTION public.guard_top_up_utr() FROM PUBLIC, anon, authenticated, service_role;
+REVOKE EXECUTE ON FUNCTION public.inherited_registration_payment(UUID, public.registration_counter) FROM PUBLIC, anon, authenticated, service_role;
+REVOKE EXECUTE ON FUNCTION public.issue_payment_pass(UUID, BOOLEAN) FROM PUBLIC, anon, authenticated;
+REVOKE EXECUTE ON FUNCTION public.resolve_registration_payment(UUID) FROM PUBLIC, anon, authenticated;
+REVOKE EXECUTE ON FUNCTION public.start_registration_phase(INTEGER, INTEGER, INTEGER, UUID) FROM PUBLIC, anon, authenticated;
+GRANT EXECUTE ON FUNCTION public.issue_payment_pass(UUID, BOOLEAN) TO service_role;
+GRANT EXECUTE ON FUNCTION public.resolve_registration_payment(UUID) TO service_role;
+GRANT EXECUTE ON FUNCTION public.start_registration_phase(INTEGER, INTEGER, INTEGER, UUID) TO service_role;
diff --git a/supabase/manual/seat_counter_and_reregistration.sql b/supabase/manual/seat_counter_and_reregistration.sql
index 27eebf6..2930a34 100644
--- a/supabase/manual/seat_counter_and_reregistration.sql
+++ b/supabase/manual/seat_counter_and_reregistration.sql
@@ -13,6 +13,7 @@
-- refused by the old one-registration-per-user index.
-- Kept out of supabase/migrations/ on purpose so the Supabase CLI never applies it automatically. Safe to re-run: it
-- never resets the counter once it exists.
+-- Once registration_phases.sql has been run, don't re-run this file: it would put back the old counter trigger.
-- ==============================================================================
-- Section 5: staff reviewing through the Supabase API may only move a PENDING registration to confirmed/rejected
diff --git a/supabase/schema.sql b/supabase/schema.sql
index e0d3c4b..974f0f8 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -1889,3 +1889,339 @@ CREATE POLICY "ID cards: never outside the owner's folder" ON storage.objects AS
ALL TO anon, authenticated
USING (bucket_id <> 'id-cards' OR (storage.foldername(name))[1] = auth.uid()::text)
WITH CHECK (bucket_id <> 'id-cards' OR (storage.foldername(name))[1] = auth.uid()::text);
+
+
+-- ==============================================================================
+-- 20. REGISTRATION PHASES
+-- ==============================================================================
+-- Registrations sell in phases, one at a time. registration_counter holds the current phase and its fee. A phase is
+-- open until closed_at is set, which happens by itself the moment seats_left reaches 0, however it got there. Only
+-- start_registration_phase() (an admin, POST /api/admin/seats/phase) opens the next one: editing the numbers never does.
+ALTER TABLE public.registration_counter ADD COLUMN IF NOT EXISTS phase INTEGER NOT NULL DEFAULT 1;
+ALTER TABLE public.registration_counter ADD COLUMN IF NOT EXISTS fee_inr INTEGER NOT NULL DEFAULT 999;
+ALTER TABLE public.registration_counter ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;
+
+-- phase: the phase whose fee it was charged at. submitted_late: submitted after its phase closed and after the time
+-- allowed for paying, so staff must check the payment date against pay_by on the bank statement. allowed_by: the staff
+-- member who allowed it through the helpdesk. amount: the full fee owed. amount_due: the part still to be paid (asked
+-- for by staff when someone paid too little: there are no refunds, so they pay the difference rather than the whole
+-- fee again), and top_up_utr is the UTR of that second payment.
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS phase INTEGER NOT NULL DEFAULT 1;
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS submitted_late BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS pay_by TIMESTAMPTZ;
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS allowed_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL;
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS amount_due INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS top_up_utr TEXT;
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS top_up_recorded_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL;
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS top_up_recorded_at TIMESTAMPTZ;
+
+DO $$
+BEGIN
+ -- 100000 matches MAX_FEE in src/lib/seats.ts.
+ ALTER TABLE public.registration_counter DROP CONSTRAINT IF EXISTS registration_counter_phase_check;
+ ALTER TABLE public.registration_counter ADD CONSTRAINT registration_counter_phase_check
+ CHECK (phase >= 1 AND fee_inr BETWEEN 1 AND 100000);
+ ALTER TABLE public.registrations DROP CONSTRAINT IF EXISTS registrations_amount_due_check;
+ ALTER TABLE public.registrations ADD CONSTRAINT registrations_amount_due_check
+ CHECK (amount_due BETWEEN 0 AND 100000);
+ ALTER TABLE public.registrations DROP CONSTRAINT IF EXISTS registrations_top_up_utr_check;
+ ALTER TABLE public.registrations ADD CONSTRAINT registrations_top_up_utr_check
+ CHECK (top_up_utr IS NULL OR top_up_utr ~ '^[0-9]{12}$');
+END $$;
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_registrations_top_up_utr ON public.registrations(top_up_utr)
+ WHERE top_up_utr IS NOT NULL;
+
+-- Closes the phase the moment the count reaches 0: in the same statement, so simultaneous registrations can't slip past.
+CREATE OR REPLACE FUNCTION public.close_registration_phase_at_zero() RETURNS TRIGGER AS $$
+BEGIN
+ IF NEW.seats_left = 0 AND NEW.closed_at IS NULL THEN
+ NEW.closed_at := now();
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SET search_path = public;
+
+DROP TRIGGER IF EXISTS trg_close_registration_phase_at_zero ON public.registration_counter;
+CREATE TRIGGER trg_close_registration_phase_at_zero
+ BEFORE INSERT OR UPDATE OF seats_left ON public.registration_counter
+ FOR EACH ROW EXECUTE FUNCTION public.close_registration_phase_at_zero();
+
+UPDATE public.registration_counter SET closed_at = now() WHERE seats_left = 0 AND closed_at IS NULL;
+
+-- Replaces section 17's version. The count only moves for a registration of the current phase while that phase is open:
+-- a rejection after closing can't reopen sales, and a late or older-phase registration doesn't touch the new phase's
+-- count. The WHERE clause does the check, so it holds even if a phase closes at the same moment.
+CREATE OR REPLACE FUNCTION public.move_registration_counter() RETURNS TRIGGER AS $$
+DECLARE
+ v_held BOOLEAN := false;
+ v_holds BOOLEAN := false;
+ v_phase INTEGER;
+BEGIN
+ IF TG_OP <> 'INSERT' THEN
+ v_held := COALESCE(OLD.status IN ('pending', 'confirmed'), false);
+ v_phase := OLD.phase;
+ END IF;
+ IF TG_OP <> 'DELETE' THEN
+ v_holds := COALESCE(NEW.status IN ('pending', 'confirmed'), false);
+ v_phase := NEW.phase;
+ END IF;
+ IF v_holds AND NOT v_held THEN
+ UPDATE public.registration_counter SET seats_left = GREATEST(seats_left - 1, 0)
+ WHERE id = 1 AND phase = v_phase AND closed_at IS NULL;
+ ELSIF v_held AND NOT v_holds THEN
+ UPDATE public.registration_counter SET seats_left = LEAST(seats_left + 1, total_seats)
+ WHERE id = 1 AND phase = v_phase AND closed_at IS NULL;
+ END IF;
+ RETURN NULL;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
+
+-- Replaces section 5's version: the fee is the current phase's, and the phase columns can't be chosen by the inserter.
+CREATE OR REPLACE FUNCTION public.enforce_registration_insert() RETURNS TRIGGER AS $$
+DECLARE
+ v_email TEXT;
+ v_confirmed TIMESTAMPTZ;
+BEGIN
+ IF COALESCE(auth.role(), '') <> 'authenticated' THEN
+ RETURN NEW;
+ END IF;
+
+ SELECT lower(email), email_confirmed_at INTO v_email, v_confirmed FROM auth.users WHERE id = auth.uid();
+
+ NEW.user_id := auth.uid();
+ NEW.email := COALESCE(v_email, '');
+ NEW.registration_id := 'IV26-' || (1000 + (('x' || substr(md5(gen_random_uuid()::text), 1, 7))::bit(28)::int % 9000))::text;
+ NEW.reviewed_by := NULL;
+ NEW.reviewed_at := NULL;
+ NEW.student_type := CASE WHEN v_email LIKE '%@nitrkl.ac.in' AND v_confirmed IS NOT NULL THEN 'internal' ELSE 'external' END;
+ NEW.status := 'pending';
+ SELECT fee_inr, phase INTO NEW.amount, NEW.phase FROM public.registration_counter WHERE id = 1;
+ NEW.submitted_late := false;
+ NEW.pay_by := NULL;
+ NEW.allowed_by := NULL;
+ NEW.amount_due := 0;
+ NEW.top_up_utr := NULL;
+ NEW.top_up_recorded_by := NULL;
+ NEW.top_up_recorded_at := NULL;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
+
+-- Refuses approval while part of the fee is still due, whoever updates: the service role included.
+-- /api/admin/registrations looks for this message.
+CREATE OR REPLACE FUNCTION public.require_amount_paid_for_approval() RETURNS TRIGGER AS $$
+BEGIN
+ IF NEW.status = 'confirmed' AND OLD.status IS DISTINCT FROM 'confirmed' AND NEW.amount_due > 0 THEN
+ RAISE EXCEPTION 'Amount due before approval' USING ERRCODE = '23514';
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SET search_path = public;
+
+DROP TRIGGER IF EXISTS trg_require_amount_paid_for_approval ON public.registrations;
+CREATE TRIGGER trg_require_amount_paid_for_approval
+ BEFORE UPDATE OF status ON public.registrations
+ FOR EACH ROW EXECUTE FUNCTION public.require_amount_paid_for_approval();
+
+-- One UPI transaction pays for one thing: a top-up's UTR can't be any registration's UTR, in either direction. The same
+-- code and wording as uq_registrations_utr, so /api/register answers it the same way.
+CREATE OR REPLACE FUNCTION public.guard_top_up_utr() RETURNS TRIGGER AS $$
+BEGIN
+ IF NEW.top_up_utr IS NOT NULL AND (
+ NEW.top_up_utr = NEW.utr
+ OR EXISTS (SELECT 1 FROM public.registrations r WHERE r.utr = NEW.top_up_utr AND r.status <> 'rejected' AND r.id <> NEW.id)
+ ) THEN
+ RAISE EXCEPTION 'This utr is already used by a registration' USING ERRCODE = '23505';
+ END IF;
+ IF NEW.utr IS NOT NULL
+ AND EXISTS (SELECT 1 FROM public.registrations r WHERE r.top_up_utr = NEW.utr AND r.id <> NEW.id) THEN
+ RAISE EXCEPTION 'This utr is already used by a top-up' USING ERRCODE = '23505';
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SET search_path = public;
+
+DROP TRIGGER IF EXISTS trg_guard_top_up_utr ON public.registrations;
+CREATE TRIGGER trg_guard_top_up_utr
+ BEFORE INSERT OR UPDATE OF utr, top_up_utr ON public.registrations
+ FOR EACH ROW EXECUTE FUNCTION public.guard_top_up_utr();
+
+-- A payment pass is the server's record of the fee quoted to a student. The form shows payment details only with a pass
+-- and until pay_by; the student may submit at that fee until submit_by, even after the phase closes or the fee rises.
+-- issued_by is set when staff allowed a payment through the helpdesk (POST /api/admin/registrations/payment-pass); note
+-- says why. used_at is set once it is turned into a registration, or replaced by a newer pass. Only the server reads
+-- and writes these.
+CREATE TABLE IF NOT EXISTS public.payment_passes (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
+ phase INTEGER NOT NULL,
+ amount INTEGER NOT NULL CHECK (amount BETWEEN 1 AND 100000),
+ pay_by TIMESTAMPTZ NOT NULL,
+ submit_by TIMESTAMPTZ NOT NULL,
+ issued_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
+ note TEXT CHECK (note IS NULL OR char_length(note) <= 300),
+ used_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_payment_passes_user ON public.payment_passes(user_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_payment_passes_in_progress ON public.payment_passes(pay_by) WHERE used_at IS NULL;
+
+ALTER TABLE public.payment_passes ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON public.payment_passes FROM PUBLIC, anon, authenticated;
+GRANT ALL ON public.payment_passes TO service_role;
+
+-- The registration a student may re-register from without paying again: their latest, rejected for a reason that doesn't
+-- question the payment itself, at a fee that isn't the current open one (otherwise they just get the current fee).
+CREATE OR REPLACE FUNCTION public.inherited_registration_payment(p_user_id UUID, p_counter public.registration_counter)
+RETURNS public.registrations AS $$
+DECLARE
+ r public.registrations%ROWTYPE;
+BEGIN
+ SELECT * INTO r FROM public.registrations WHERE user_id = p_user_id ORDER BY created_at DESC LIMIT 1;
+ IF FOUND AND r.status = 'rejected' AND r.rejection_reason IN ('unclear_screenshot', 'utr_mismatch') AND r.amount > 0
+ AND (p_counter.closed_at IS NOT NULL OR r.phase <> p_counter.phase) THEN
+ RETURN r;
+ END IF;
+ RETURN NULL;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
+
+-- POST /api/register/start-payment. Answers:
+-- state 'pay': a pass, show the payment details until pay_by.
+-- state 'already_paid': a pass, but the time to pay has passed: no payment details, only "already paid? continue".
+-- current_fee is set when a phase is open, so the student can pay that instead.
+-- state 'closed': no pass and no phase open.
+-- p_fresh: the student hasn't paid and wants the current fee, replacing any older pass. Ignored while closed.
+-- FOR SHARE orders this against a registration closing the phase: a pass issued a moment before closing stands.
+CREATE OR REPLACE FUNCTION public.issue_payment_pass(p_user_id UUID, p_fresh BOOLEAN DEFAULT false)
+RETURNS JSONB AS $$
+DECLARE
+ c public.registration_counter%ROWTYPE;
+ p public.payment_passes%ROWTYPE;
+ r public.registrations%ROWTYPE;
+ v_open BOOLEAN;
+ v_state TEXT := 'pay';
+BEGIN
+ SELECT * INTO c FROM public.registration_counter WHERE id = 1 FOR SHARE;
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'registration_counter has no row';
+ END IF;
+ v_open := c.closed_at IS NULL;
+
+ IF NOT (p_fresh AND v_open) THEN
+ SELECT * INTO p FROM public.payment_passes
+ WHERE user_id = p_user_id AND used_at IS NULL AND submit_by > now()
+ ORDER BY created_at DESC LIMIT 1;
+ IF FOUND THEN
+ IF p.pay_by <= now() THEN
+ v_state := 'already_paid';
+ END IF;
+ -- The student's own pass in the phase still open simply renews below, at the same fee.
+ IF p.pay_by > now() OR p.issued_by IS NOT NULL OR NOT v_open OR p.phase <> c.phase THEN
+ RETURN jsonb_build_object(
+ 'state', v_state, 'amount', p.amount, 'phase', p.phase,
+ 'pay_seconds', GREATEST(EXTRACT(EPOCH FROM p.pay_by - now()), 0)::INT,
+ 'current_fee', CASE WHEN v_open THEN c.fee_inr END);
+ END IF;
+ ELSE
+ r := public.inherited_registration_payment(p_user_id, c);
+ IF r.id IS NOT NULL THEN
+ INSERT INTO public.payment_passes (user_id, phase, amount, pay_by, submit_by)
+ VALUES (p_user_id, r.phase, r.amount::INT, now(), now() + interval '24 hours')
+ RETURNING * INTO p;
+ RETURN jsonb_build_object(
+ 'state', 'already_paid', 'amount', p.amount, 'phase', p.phase, 'pay_seconds', 0,
+ 'current_fee', CASE WHEN v_open THEN c.fee_inr END);
+ END IF;
+ END IF;
+ END IF;
+
+ IF NOT v_open THEN
+ RETURN jsonb_build_object('state', 'closed');
+ END IF;
+
+ UPDATE public.payment_passes SET used_at = now() WHERE user_id = p_user_id AND used_at IS NULL;
+ -- 15 minutes to pay, 24 hours to submit. After that: the current fee, or the helpdesk.
+ INSERT INTO public.payment_passes (user_id, phase, amount, pay_by, submit_by)
+ VALUES (p_user_id, c.phase, c.fee_inr, now() + interval '15 minutes', now() + interval '24 hours')
+ RETURNING * INTO p;
+ RETURN jsonb_build_object(
+ 'state', 'pay', 'amount', p.amount, 'phase', p.phase,
+ 'pay_seconds', GREATEST(EXTRACT(EPOCH FROM p.pay_by - now()), 0)::INT,
+ 'current_fee', c.fee_inr);
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
+
+-- POST /api/register: what this submit is charged and whether it may go ahead at all. Never reads the request: a pass
+-- first, then a payment re-submitted after a rejection, then the current fee if a phase is open. Otherwise refused.
+CREATE OR REPLACE FUNCTION public.resolve_registration_payment(p_user_id UUID)
+RETURNS JSONB AS $$
+DECLARE
+ c public.registration_counter%ROWTYPE;
+ p public.payment_passes%ROWTYPE;
+ r public.registrations%ROWTYPE;
+BEGIN
+ SELECT * INTO c FROM public.registration_counter WHERE id = 1 FOR SHARE;
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'registration_counter has no row';
+ END IF;
+
+ SELECT * INTO p FROM public.payment_passes
+ WHERE user_id = p_user_id AND used_at IS NULL AND submit_by > now()
+ ORDER BY created_at DESC LIMIT 1;
+ IF FOUND THEN
+ RETURN jsonb_build_object(
+ 'allowed', true, 'amount', p.amount, 'phase', p.phase, 'pass_id', p.id, 'pay_by', p.pay_by,
+ 'allowed_by', p.issued_by,
+ 'late', (c.closed_at IS NOT NULL OR p.phase <> c.phase) AND now() > p.pay_by);
+ END IF;
+
+ r := public.inherited_registration_payment(p_user_id, c);
+ IF r.id IS NOT NULL THEN
+ RETURN jsonb_build_object('allowed', true, 'amount', r.amount, 'phase', r.phase, 'late', true);
+ END IF;
+
+ IF c.closed_at IS NULL THEN
+ RETURN jsonb_build_object('allowed', true, 'amount', c.fee_inr, 'phase', c.phase, 'late', false);
+ END IF;
+ RETURN jsonb_build_object('allowed', false);
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
+
+-- POST /api/admin/seats/phase. The only way to reopen. Refused while a phase is open, so a double click or a stale
+-- screen can't skip a phase or change the fee under students who are paying.
+CREATE OR REPLACE FUNCTION public.start_registration_phase(p_fee INTEGER, p_total INTEGER, p_left INTEGER, p_admin UUID)
+RETURNS VOID AS $$
+DECLARE
+ c public.registration_counter%ROWTYPE;
+BEGIN
+ SELECT * INTO c FROM public.registration_counter WHERE id = 1 FOR UPDATE;
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'registration_counter has no row';
+ END IF;
+ IF c.closed_at IS NULL THEN
+ RAISE EXCEPTION 'Phase % is still open', c.phase USING ERRCODE = '55000';
+ END IF;
+ IF p_left < 1 THEN
+ RAISE EXCEPTION 'A new phase needs at least one seat' USING ERRCODE = '23514';
+ END IF;
+ UPDATE public.registration_counter
+ SET phase = c.phase + 1, fee_inr = p_fee, total_seats = p_total, seats_left = p_left, closed_at = NULL,
+ updated_by = p_admin, updated_at = now()
+ WHERE id = 1;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
+
+-- Only the server may call these; the triggers need no grant.
+REVOKE EXECUTE ON FUNCTION public.close_registration_phase_at_zero() FROM PUBLIC, anon, authenticated, service_role;
+REVOKE EXECUTE ON FUNCTION public.require_amount_paid_for_approval() FROM PUBLIC, anon, authenticated, service_role;
+REVOKE EXECUTE ON FUNCTION public.guard_top_up_utr() FROM PUBLIC, anon, authenticated, service_role;
+REVOKE EXECUTE ON FUNCTION public.inherited_registration_payment(UUID, public.registration_counter) FROM PUBLIC, anon, authenticated, service_role;
+REVOKE EXECUTE ON FUNCTION public.issue_payment_pass(UUID, BOOLEAN) FROM PUBLIC, anon, authenticated;
+REVOKE EXECUTE ON FUNCTION public.resolve_registration_payment(UUID) FROM PUBLIC, anon, authenticated;
+REVOKE EXECUTE ON FUNCTION public.start_registration_phase(INTEGER, INTEGER, INTEGER, UUID) FROM PUBLIC, anon, authenticated;
+GRANT EXECUTE ON FUNCTION public.issue_payment_pass(UUID, BOOLEAN) TO service_role;
+GRANT EXECUTE ON FUNCTION public.resolve_registration_payment(UUID) TO service_role;
+GRANT EXECUTE ON FUNCTION public.start_registration_phase(INTEGER, INTEGER, INTEGER, UUID) TO service_role;