Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
dde7f9a
feature(stripe): implement api route in cms folder that creates membe…
deb-coder-man Apr 27, 2026
8602072
feat/styling and seperating out the sign up page
Richman-Tan Apr 28, 2026
eb79aad
feature(stripe): changed sign up logic so members get created in CMS …
deb-coder-man Apr 28, 2026
f92dfb7
Merge branch 'feature/stripe' into feature/signup-page
Richman-Tan Apr 29, 2026
fee4111
chore/running lint
Richman-Tan Apr 29, 2026
5fe5237
fix: changes
Richman-Tan May 1, 2026
e814607
chore: fix prettier formatting in signup page
Richman-Tan May 1, 2026
926eab5
Potential fix for pull request finding
Richman-Tan May 1, 2026
75d0fc4
Potential fix for pull request finding
Richman-Tan May 1, 2026
d615dec
Potential fix for pull request finding
Richman-Tan May 1, 2026
5cdeb65
Potential fix for pull request finding
Richman-Tan May 1, 2026
0403ea9
refactor: improve member creation logic and error handling in Stripe …
Richman-Tan May 1, 2026
03f27ef
Potential fix for pull request finding
Richman-Tan May 1, 2026
3fc4877
Potential fix for pull request finding
Richman-Tan May 1, 2026
7c58b97
Potential fix for pull request finding
Richman-Tan May 1, 2026
dc5ca6c
fix: apply review 4209899554 - validation, pending member update, Str…
Copilot May 1, 2026
33ffb97
refactor: enhance SelectField component with dropdown icon and improv…
Richman-Tan May 1, 2026
2ca3790
chore: fix prettier formatting in signup page
Richman-Tan May 1, 2026
be9c12b
feat: implement multi-step signup form with validation and payment pr…
Richman-Tan May 1, 2026
ed3f563
Merge branch 'main' into feature/signup-page
Richman-Tan May 2, 2026
13f304d
feat: refactor signup components and add new input fields with improv…
Richman-Tan May 2, 2026
6f876cb
refactor: improve code formatting and readability in CardSection, Pay…
Richman-Tan May 2, 2026
afeaf11
feat: enhance signup form with additional input fields and validation…
Richman-Tan May 3, 2026
3db8350
fix: apply review 4215882932 - aria attributes, copy fix, error handl…
Copilot May 3, 2026
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
2 changes: 1 addition & 1 deletion cms/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
DATABASE_URL=mongodb://127.0.0.1/your-database-name
DATABASE_URL=postgresql://127.0.0.1:5432/your-database-name
PAYLOAD_SECRET=YOUR_SECRET_HERE

# Stripe — copy keys from Stripe Dashboard > Developers
Expand Down
190 changes: 157 additions & 33 deletions cms/src/app/stripe/checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,78 +2,202 @@ import { NextRequest } from 'next/server'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import Stripe from 'stripe'
import { createCipheriv, randomBytes } from 'crypto'

function encryptPassword(password: string, hexKey: string): string {
const key = Buffer.from(hexKey, 'hex')
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, iv)
const encrypted = Buffer.concat([cipher.update(password, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([iv, tag, encrypted]).toString('base64')
}

export const POST = async (request: NextRequest) => {
let body: { name?: string; email?: string; password?: string; phone?: string }
let body: {
name?: string
email?: string
password?: string
Comment thread
joengy marked this conversation as resolved.
Comment thread
joengy marked this conversation as resolved.
phone?: string
upi?: string
studentId?: string
areaOfStudy?: string
yearOfUniversity?: '1' | '2' | '3' | '4' | '5+' | 'postgrad'
gender?: 'male' | 'female' | 'non-binary' | 'prefer-not-to-say'
ethnicity?: 'chinese' | 'malay' | 'indian' | 'eurasian' | 'other'
returningMember?: boolean
}
try {
body = await request.json()
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}

const { name, email, password, phone } = body
if (!name || !email || !password || !phone) {
return Response.json({ error: 'Missing required fields: name, email, password, phone' }, { status: 400 })
const {
name,
email,
password,
phone,
upi,
studentId,
areaOfStudy,
yearOfUniversity,
gender,
ethnicity,
returningMember,
} = body
if (
!name ||
!email ||
!password ||
!phone ||
!upi ||
!studentId ||
!areaOfStudy ||
!yearOfUniversity ||
!gender ||
!ethnicity ||
returningMember === undefined
) {
Comment thread
Richman-Tan marked this conversation as resolved.
return Response.json(
{
error:
'Missing required fields: name, email, password, phone, upi, studentId, areaOfStudy, yearOfUniversity, gender, ethnicity, returningMember',
},
{ status: 400 },
)
}
Comment thread
joengy marked this conversation as resolved.
Comment thread
joengy marked this conversation as resolved.

const stripeSecretKey = process.env.STRIPE_SECRET_KEY
const priceId = process.env.STRIPE_PRICE_ID
const webUrl = process.env.WEB_URL || 'http://localhost:3000'
const encryptionKey = process.env.SIGNUP_ENCRYPTION_KEY

if (!stripeSecretKey || !priceId) {
return Response.json({ error: 'Stripe not configured' }, { status: 500 })
}
if (!encryptionKey || encryptionKey.length !== 64) {
return Response.json({ error: 'Signup encryption not configured' }, { status: 500 })
}

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

// Reuse an existing pending member so a transient Stripe failure doesn't
// permanently block the email from retrying.
let memberId: number
let memberCreatedHere = false
let existingStripeCustomerId: string | null | undefined

// Check for duplicate email before sending the user to Stripe
const existing = await payload.find({
collection: 'members',
where: { email: { equals: email } },
where: { and: [{ email: { equals: email } }, { status: { equals: 'pending' } }] },
limit: 1,
})
if (existing.totalDocs > 0) {
return Response.json({ error: 'An account with this email already exists' }, { status: 409 })
}

const stripe = new Stripe(stripeSecretKey, { apiVersion: '2026-04-22.dahlia' })
if (existing.docs.length > 0) {
const existingDoc = existing.docs[0]
memberId = existingDoc.id
existingStripeCustomerId = existingDoc.stripeCustomerId
// Update the existing pending member with the latest submitted details,
// including password so a retry after correcting credentials works correctly.
await payload.update({
collection: 'members',
id: memberId,
overrideAccess: true,
data: {
name,
phone,
password,
upi,
studentId,
areaOfStudy,
yearOfUniversity,
gender,
ethnicity,
returningMember,
},
})
Comment thread
joengy marked this conversation as resolved.
Comment thread
Richman-Tan marked this conversation as resolved.
Comment on lines +84 to +106
} else {
try {
const member = await payload.create({
collection: 'members',
Comment thread
Richman-Tan marked this conversation as resolved.
// This is a trusted server-side route that creates only a pending
// member record for the Stripe checkout flow, so bypass collection
// create access here explicitly.
Comment thread
joengy marked this conversation as resolved.
overrideAccess: true,
data: {
name,
email,
password,
phone,
upi,
studentId,
areaOfStudy,
yearOfUniversity,
gender,
ethnicity,
returningMember,
status: 'pending',
},
})
memberId = member.id
memberCreatedHere = true
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to create member'
if (message.toLowerCase().includes('duplicate') || message.toLowerCase().includes('unique')) {
Comment thread
Richman-Tan marked this conversation as resolved.
return Response.json({ error: 'An account with this email already exists' }, { status: 409 })
}
return Response.json({ error: message }, { status: 400 })
}
}
Comment thread
joengy marked this conversation as resolved.

// Reuse the existing Stripe customer if this pending member already has one.
// This prevents orphaned customers from accumulating on retries.
let customerId: string
try {
const customer = await stripe.customers.create({ email, name })
customerId = customer.id
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to create Stripe customer'
return Response.json({ error: message }, { status: 502 })
let customerCreatedHere = false
if (existingStripeCustomerId) {
customerId = existingStripeCustomerId
} else {
try {
const customer = await stripe.customers.create({ email, name })
customerId = customer.id
customerCreatedHere = true
// Persist the customer ID so future retries can reuse it.
await payload
.update({
collection: 'members',
id: memberId,
overrideAccess: true,
data: { stripeCustomerId: customerId },
})
.catch(() => {})
} catch (err: unknown) {
if (memberCreatedHere) {
await payload.delete({ collection: 'members', id: memberId }).catch(() => {})
}
const message = err instanceof Error ? err.message : 'Failed to create Stripe customer'
return Response.json({ error: message }, { status: 502 })
}
}

const encryptedPassword = encryptPassword(password, encryptionKey)

try {
const session = await stripe.checkout.sessions.create({
mode: 'payment',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${webUrl}/signup/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${webUrl}/signup?cancelled=true`,
metadata: { name, email, phone, encryptedPassword },
metadata: { memberId: String(memberId) },
})
Comment thread
joengy marked this conversation as resolved.
Comment thread
joengy marked this conversation as resolved.

if (!session.url) {
if (memberCreatedHere) {
await payload.delete({ collection: 'members', id: memberId }).catch(() => {})
if (customerCreatedHere) {
await stripe.customers.del(customerId).catch(() => {})
}
}
return Response.json(
{ error: 'Stripe did not provide a checkout URL for the created session' },
{ status: 502 },
)
}

return Response.json({ checkoutUrl: session.url })
} catch (err: unknown) {
if (memberCreatedHere) {
await payload.delete({ collection: 'members', id: memberId }).catch(() => {})
if (customerCreatedHere) {
await stripe.customers.del(customerId).catch(() => {})
}
}
Comment thread
Richman-Tan marked this conversation as resolved.
const message = err instanceof Error ? err.message : 'Failed to create Stripe checkout session'
return Response.json({ error: message }, { status: 502 })
}
Comment thread
joengy marked this conversation as resolved.
Comment on lines 143 to 203
Expand Down
55 changes: 13 additions & 42 deletions cms/src/app/stripe/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,10 @@ import { NextRequest } from 'next/server'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import Stripe from 'stripe'
import { createDecipheriv } from 'crypto'

function decryptPassword(encrypted: string, hexKey: string): string {
const key = Buffer.from(hexKey, 'hex')
const buf = Buffer.from(encrypted, 'base64')
const iv = buf.subarray(0, 12)
const tag = buf.subarray(12, 28)
const ciphertext = buf.subarray(28)
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')
}

export const POST = async (request: NextRequest) => {
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
const encryptionKey = process.env.SIGNUP_ENCRYPTION_KEY

if (!stripeSecretKey || !webhookSecret) {
return Response.json({ error: 'Stripe not configured' }, { status: 500 })
Expand Down Expand Up @@ -49,49 +36,33 @@ export const POST = async (request: NextRequest) => {
return Response.json({ received: true })
}

const { name, email, phone, encryptedPassword } = session.metadata ?? {}
const stripeCustomerId = session.customer as string | null
const { memberId: rawMemberId } = session.metadata ?? {}
const stripeCustomerId =
typeof session.customer === 'string' ? session.customer : session.customer?.id ?? null

if (!name || !email || !phone || !encryptedPassword) {
console.error('Stripe webhook: missing user data in session metadata', { sessionId: session.id })
const memberId = Number(rawMemberId)
if (!Number.isFinite(memberId)) {
console.error('Stripe webhook: invalid or missing memberId in session metadata', {
sessionId: session.id,
rawMemberId,
})
return Response.json({ received: true })
}

if (!encryptionKey) {
console.error('Stripe webhook: SIGNUP_ENCRYPTION_KEY not configured')
return Response.json({ error: 'Encryption not configured' }, { status: 500 })
}

let password: string
try {
password = decryptPassword(encryptedPassword, encryptionKey)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to decrypt password'
console.error('Stripe webhook: failed to decrypt password', { sessionId: session.id, error: message })
return Response.json({ error: message }, { status: 500 })
}

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

try {
await payload.create({
await payload.update({
collection: 'members',
id: memberId,
data: {
name,
email,
password,
phone,
status: 'active',
...(stripeCustomerId ? { stripeCustomerId } : {}),
},
})
Comment thread
joengy marked this conversation as resolved.
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to create member'
// If the member already exists (duplicate webhook delivery), treat as success
if (message.toLowerCase().includes('duplicate') || message.toLowerCase().includes('unique')) {
return Response.json({ received: true })
}
console.error('Stripe webhook: failed to create member', { email, error: message })
const message = err instanceof Error ? err.message : 'Failed to update member'
console.error('Stripe webhook: failed to update member', { memberId, error: message })
// Return 500 so Stripe retries with exponential backoff
return Response.json({ error: message }, { status: 500 })
}
Expand Down
56 changes: 53 additions & 3 deletions cms/src/collections/Members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export const Members: CollectionConfig = {
slug: 'members',
auth: true,
access: {
create: () => true,
create: () => false,
},
admin: {
useAsTitle: 'name',
Expand Down Expand Up @@ -55,6 +55,56 @@ export const Members: CollectionConfig = {
{
name: 'emergencyContactPhone',
type: 'text',
}
},
{
name: 'upi',
type: 'text',
},
{
name: 'studentId',
type: 'text',
},
{
name: 'areaOfStudy',
type: 'text',
},
{
name: 'yearOfUniversity',
type: 'select',
options: [
{ label: 'Year 1', value: '1' },
{ label: 'Year 2', value: '2' },
{ label: 'Year 3', value: '3' },
{ label: 'Year 4', value: '4' },
{ label: 'Year 5+', value: '5+' },
{ label: 'Postgraduate', value: 'postgrad' },
],
},
{
name: 'gender',
type: 'select',
options: [
{ label: 'Male', value: 'male' },
{ label: 'Female', value: 'female' },
{ label: 'Non-binary', value: 'non-binary' },
{ label: 'Prefer not to say', value: 'prefer-not-to-say' },
],
},
{
name: 'ethnicity',
type: 'select',
options: [
{ label: 'Chinese', value: 'chinese' },
{ label: 'Malay', value: 'malay' },
{ label: 'Indian', value: 'indian' },
{ label: 'Eurasian', value: 'eurasian' },
{ label: 'Other', value: 'other' },
],
},
{
name: 'returningMember',
type: 'checkbox',
defaultValue: false,
},
]
}
}
Loading
Loading