Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cms/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ STRIPE_PRICE_ID=price...

# URL of the public web app (used for Stripe success/cancel redirects)
WEB_URL=http://localhost:3000

# Secret used to temporarily encrypt signup passwords until Stripe confirms payment
SIGNUP_ENCRYPTION_KEY=YOUR_SIGNUP_ENCRYPTION_KEY_HERE
56 changes: 56 additions & 0 deletions cms/src/app/signup/check-email/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'

function normalizeEmail(email: string) {
return email.trim().toLowerCase()
}

function isValidEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}

export async function POST(request: Request) {
let body: { email?: string }

try {
body = await request.json()
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}

const email = normalizeEmail(body.email ?? '')

if (!isValidEmail(email)) {
return Response.json({ error: 'Valid email is required' }, { status: 400 })
}

const payload = await getPayload({ config: configPromise })

const [existingUser, existingRegisteredMember] = await Promise.all([
payload.find({
collection: 'users',
limit: 1,
overrideAccess: true,
pagination: false,
where: { email: { equals: email } },
}),
payload.find({
collection: 'members',
limit: 1,
overrideAccess: true,
pagination: false,
where: {
and: [{ email: { equals: email } }, { status: { not_equals: 'pending' } }],
},
}),
])

if (existingUser.docs.length > 0 || existingRegisteredMember.docs.length > 0) {
return Response.json(
{ available: false, error: 'An account with this email already exists' },
{ status: 409 },
)
}

return Response.json({ available: true })
}
118 changes: 118 additions & 0 deletions cms/src/app/stripe/_lib/activatePaidSignup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { Payload } from 'payload'

import { decryptSignupPassword } from './signupPassword'

type ActivatePaidSignupArgs = {
memberId: number
payload: Payload
stripeCustomerId?: null | string
}

type MemberWithSignupPassword = {
areaOfStudy?: null | string
authProvider?: 'email' | 'google' | null
email: string
encryptedSignupPassword?: null | string
ethnicity?: 'chinese' | 'eurasian' | 'indian' | 'malay' | 'other' | null
firstName?: null | string
gender?: 'female' | 'male' | 'non-binary' | 'prefer-not-to-say' | null
googleSub?: null | string
id: number
lastName?: null | string
membershipExpiryDate?: null | string
name: string
phone: string
returningMember?: boolean | null
studentId?: null | string
upi?: null | string
yearOfUniversity?: '1' | '2' | '3' | '4' | '5+' | 'postgrad' | null
}

function splitName(name: string) {
const [firstName, ...rest] = name.trim().split(/\s+/)

return {
firstName: firstName || undefined,
lastName: rest.join(' ') || undefined,
}
}

export async function activatePaidSignup({
memberId,
payload,
stripeCustomerId,
}: ActivatePaidSignupArgs) {
const member = (await payload.findByID({
collection: 'members',
id: memberId,
overrideAccess: true,
showHiddenFields: true,
})) as MemberWithSignupPassword

const existingUser = await payload.find({
collection: 'users',
limit: 1,
overrideAccess: true,
pagination: false,
where: {
email: {
equals: member.email,
},
},
})

const fallbackName = splitName(member.name)
const userData = {
name: member.name,
authProvider: member.authProvider || 'email',
googleSub: member.googleSub,
firstName: member.firstName || fallbackName.firstName,
lastName: member.lastName || fallbackName.lastName,
phone: member.phone,
membershipStatus: 'active' as const,
membershipExpiryDate: member.membershipExpiryDate,
stripeCustomerId,
upi: member.upi,
studentId: member.studentId,
areaOfStudy: member.areaOfStudy,
yearOfUniversity: member.yearOfUniversity,
gender: member.gender,
ethnicity: member.ethnicity,
returningMember: member.returningMember ?? false,
}

if (existingUser.docs.length === 0) {
if (!member.encryptedSignupPassword) {
throw new Error('Paid member is missing encrypted signup password')
}

await payload.create({
collection: 'users',
overrideAccess: true,
data: {
email: member.email,
password: decryptSignupPassword(member.encryptedSignupPassword),
role: 'member',
...userData,
},
})
} else {
await payload.update({
collection: 'users',
id: existingUser.docs[0].id,
overrideAccess: true,
data: userData,
})
}

await payload.update({
collection: 'members',
id: memberId,
overrideAccess: true,
data: {
status: 'active',
encryptedSignupPassword: null,
...(stripeCustomerId ? { stripeCustomerId } : {}),
},
})
}
48 changes: 48 additions & 0 deletions cms/src/app/stripe/_lib/signupPassword.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import crypto from 'crypto'

const algorithm = 'aes-256-gcm'
const version = 'v1'

function getEncryptionKey() {
const secret = process.env.SIGNUP_ENCRYPTION_KEY || process.env.PAYLOAD_SECRET

if (!secret) {
throw new Error('SIGNUP_ENCRYPTION_KEY or PAYLOAD_SECRET must be configured')
}

return crypto.createHash('sha256').update(secret).digest()
}

export function encryptSignupPassword(password: string) {
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv(algorithm, getEncryptionKey(), iv)
const encrypted = Buffer.concat([cipher.update(password, 'utf8'), cipher.final()])
const authTag = cipher.getAuthTag()

return [
version,
iv.toString('base64url'),
authTag.toString('base64url'),
encrypted.toString('base64url'),
].join(':')
}

export function decryptSignupPassword(value: string) {
const [storedVersion, iv, authTag, encrypted] = value.split(':')

if (storedVersion !== version || !iv || !authTag || !encrypted) {
throw new Error('Invalid encrypted signup password')
}

const decipher = crypto.createDecipheriv(
algorithm,
getEncryptionKey(),
Buffer.from(iv, 'base64url'),
)
decipher.setAuthTag(Buffer.from(authTag, 'base64url'))

return Buffer.concat([
decipher.update(Buffer.from(encrypted, 'base64url')),
decipher.final(),
]).toString('utf8')
}
62 changes: 62 additions & 0 deletions cms/src/app/stripe/checkout/confirm/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { NextRequest } from 'next/server'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import Stripe from 'stripe'
import { activatePaidSignup } from '../../_lib/activatePaidSignup'

export const GET = async (request: NextRequest) => {
const sessionId = request.nextUrl.searchParams.get('session_id')

if (!sessionId) {
return Response.json({ error: 'Missing session_id' }, { status: 400 })
}

const stripeSecretKey = process.env.STRIPE_SECRET_KEY

if (!stripeSecretKey) {
return Response.json({ error: 'Stripe not configured' }, { status: 500 })
}

const stripe = new Stripe(stripeSecretKey, { apiVersion: '2026-04-22.dahlia' })

let session: Stripe.Checkout.Session
try {
session = await stripe.checkout.sessions.retrieve(sessionId)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to retrieve Stripe session'
return Response.json({ error: message }, { status: 502 })
}

const memberId = Number(session.metadata?.memberId)
const stripeCustomerId =
typeof session.customer === 'string' ? session.customer : (session.customer?.id ?? null)

if (session.payment_status !== 'paid') {
return Response.json({
confirmed: false,
paymentStatus: session.payment_status,
})
}

if (!Number.isFinite(memberId)) {
return Response.json({ error: 'Stripe session is missing member metadata' }, { status: 422 })
}

const payload = await getPayload({ config: configPromise })

try {
await activatePaidSignup({
memberId,
payload,
stripeCustomerId,
})
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to activate paid signup'
return Response.json({ error: message }, { status: 500 })
}

return Response.json({
confirmed: true,
paymentStatus: session.payment_status,
})
}
Loading
Loading