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