From b5b10d7e1ef549606f3304f84a0ed602f3a9abbf Mon Sep 17 00:00:00 2001 From: hajunk05 Date: Mon, 25 May 2026 18:58:17 +1200 Subject: [PATCH 1/7] feat(payload): add refunded option to registration paymentStatus --- src/payload-types.ts | 2 +- src/payload/collections/SocialSessionRegistrations.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/payload-types.ts b/src/payload-types.ts index 3185171..f074fde 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -495,7 +495,7 @@ export interface SocialSessionRegistration { /** * Payment state for paid social sessions. */ - paymentStatus?: ('pending' | 'paid' | 'free') | null; + paymentStatus?: ('pending' | 'paid' | 'free' | 'refunded') | null; updatedAt: string; createdAt: string; } diff --git a/src/payload/collections/SocialSessionRegistrations.ts b/src/payload/collections/SocialSessionRegistrations.ts index cd6ecc1..47b2e21 100644 --- a/src/payload/collections/SocialSessionRegistrations.ts +++ b/src/payload/collections/SocialSessionRegistrations.ts @@ -140,6 +140,7 @@ export const SocialSessionRegistrations: CollectionConfig = { { label: "Pending", value: "pending" }, { label: "Paid", value: "paid" }, { label: "Free", value: "free" }, + { label: "Refunded", value: "refunded" }, ], access: { update: ({ req }) => req.user?.collection === "admin", From 726345c9e7ba69cb06a455301c08285ea00c524d Mon Sep 17 00:00:00 2001 From: hajunk05 Date: Mon, 25 May 2026 19:04:14 +1200 Subject: [PATCH 2/7] refactor(payload): skip confirmation email for pending paid registrations --- src/payload/hooks/socialSessionRegistrations/checkCapacity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/payload/hooks/socialSessionRegistrations/checkCapacity.ts b/src/payload/hooks/socialSessionRegistrations/checkCapacity.ts index 5c725dc..8cfca9e 100644 --- a/src/payload/hooks/socialSessionRegistrations/checkCapacity.ts +++ b/src/payload/hooks/socialSessionRegistrations/checkCapacity.ts @@ -87,7 +87,7 @@ export const checkCapacity: CollectionBeforeChangeHook = async ({ data, operatio recipientEmail = data.guestEmail } - if (recipientEmail) { + if (recipientEmail && data.paymentStatus !== "pending") { await sendRegistrationConfirmation({ to: recipientEmail, sessionTitle: session.title, From 0a6af4e31f6aaa4e6f6588d81786c59a692da73b Mon Sep 17 00:00:00 2001 From: hajunk05 Date: Mon, 25 May 2026 21:19:12 +1200 Subject: [PATCH 3/7] feat(stripe): add webhook route with signature verification --- src/app/api/stripe/webhook/route.ts | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/app/api/stripe/webhook/route.ts diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..cfce829 --- /dev/null +++ b/src/app/api/stripe/webhook/route.ts @@ -0,0 +1,38 @@ +export const runtime = "nodejs" + +import type { Stripe } from "stripe" +import { stripe } from "@/lib/stripe" + +const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET ?? "" + +export async function POST(req: Request) { + const body = await req.text() + const sig = req.headers.get("stripe-signature") + + if (!sig) { + return new Response("Missing stripe-signature header", { status: 400 }) + } + + let event: Stripe.Event + try { + event = stripe.webhooks.constructEvent(body, sig, webhookSecret) + } catch { + return new Response("Webhook signature verification failed", { status: 400 }) + } + + try { + switch (event.type) { + case "checkout.session.completed": + case "checkout.session.expired": + case "payment_intent.payment_failed": + case "charge.refunded": + break + default: + break + } + } catch { + return new Response("Internal server error", { status: 500 }) + } + + return new Response(null, { status: 200 }) +} From 375824e9113e0afe02d3a765672311e856505c74 Mon Sep 17 00:00:00 2001 From: hajunk05 Date: Mon, 25 May 2026 22:03:19 +1200 Subject: [PATCH 4/7] feat(stripe): handle checkout.session.completed in webhook --- src/app/api/stripe/webhook/route.ts | 78 +++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index cfce829..b01e20a 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -1,10 +1,86 @@ export const runtime = "nodejs" +import { getPayload } from "payload" import type { Stripe } from "stripe" +import sendRegistrationConfirmation from "@/lib/email/registrationConfirmation" import { stripe } from "@/lib/stripe" +import config from "@/payload.config" +import type { SocialSession, SocialSessionRegistration } from "@/payload-types" const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET ?? "" +async function getPayloadClient() { + return getPayload({ config: await config }) +} + +async function handleCheckoutSessionCompleted(session: Stripe.Checkout.Session) { + const registrationId = session.metadata?.registrationId + if (!registrationId) return + + const payload = await getPayloadClient() + + let registration: SocialSessionRegistration + try { + registration = await payload.findByID({ + collection: "social-session-registrations", + id: registrationId, + overrideAccess: true, + }) + } catch { + return + } + + if (registration.paymentStatus === "paid") return + + await payload.update({ + collection: "social-session-registrations", + id: registration.id, + data: { + paymentStatus: "paid", + amountPaid: session.amount_total != null ? session.amount_total / 100 : undefined, + }, + overrideAccess: true, + }) + + const socialSession = + typeof registration.socialSession === "string" + ? await payload.findByID({ + collection: "social-sessions", + id: registration.socialSession, + overrideAccess: true, + }) + : (registration.socialSession as SocialSession) + + const recipientEmail = + registration.guestEmail ?? + (registration.user + ? typeof registration.user === "string" + ? ( + await payload.findByID({ + collection: "users", + id: registration.user, + overrideAccess: true, + }) + ).email + : (registration.user as { email?: string }).email + : undefined) + + if (recipientEmail && socialSession) { + await sendRegistrationConfirmation({ + to: recipientEmail, + sessionTitle: socialSession.title, + sessionDate: new Date(socialSession.date).toLocaleDateString("en-NZ", { + year: "numeric", + month: "long", + day: "numeric", + }), + sessionTime: `${socialSession.startTime}${socialSession.endTime ? ` - ${socialSession.endTime}` : ""}`, + sessionLocation: socialSession.location, + isWaitlisted: registration.registrationStatus === "waitlisted", + }) + } +} + export async function POST(req: Request) { const body = await req.text() const sig = req.headers.get("stripe-signature") @@ -23,6 +99,8 @@ export async function POST(req: Request) { try { switch (event.type) { case "checkout.session.completed": + await handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session) + break case "checkout.session.expired": case "payment_intent.payment_failed": case "charge.refunded": From cc3e94f8d250a9e15d2a84b09e5fefd0e9a07c3c Mon Sep 17 00:00:00 2001 From: hajunk05 Date: Mon, 25 May 2026 22:14:30 +1200 Subject: [PATCH 5/7] feat(stripe): cancel registration on checkout.session.expired --- src/app/api/stripe/webhook/route.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index b01e20a..ab12b7a 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -81,6 +81,33 @@ async function handleCheckoutSessionCompleted(session: Stripe.Checkout.Session) } } +async function handleCheckoutSessionExpired(session: Stripe.Checkout.Session) { + const registrationId = session.metadata?.registrationId + if (!registrationId) return + + const payload = await getPayloadClient() + + let registration: SocialSessionRegistration + try { + registration = await payload.findByID({ + collection: "social-session-registrations", + id: registrationId, + overrideAccess: true, + }) + } catch { + return + } + + if (registration.registrationStatus === "cancelled") return + + await payload.update({ + collection: "social-session-registrations", + id: registration.id, + data: { registrationStatus: "cancelled" }, + overrideAccess: true, + }) +} + export async function POST(req: Request) { const body = await req.text() const sig = req.headers.get("stripe-signature") @@ -102,6 +129,8 @@ export async function POST(req: Request) { await handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session) break case "checkout.session.expired": + await handleCheckoutSessionExpired(event.data.object as Stripe.Checkout.Session) + break case "payment_intent.payment_failed": case "charge.refunded": break From d373e94b0dcafc2591fd23eb13b362f967057667 Mon Sep 17 00:00:00 2001 From: hajunk05 Date: Mon, 25 May 2026 22:22:47 +1200 Subject: [PATCH 6/7] feat(stripe): cancel registration on payment_intent.payment_failed --- src/app/api/stripe/webhook/route.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index ab12b7a..867ce26 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -108,6 +108,33 @@ async function handleCheckoutSessionExpired(session: Stripe.Checkout.Session) { }) } +async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) { + const registrationId = paymentIntent.metadata?.registrationId + if (!registrationId) return + + const payload = await getPayloadClient() + + let registration: SocialSessionRegistration + try { + registration = await payload.findByID({ + collection: "social-session-registrations", + id: registrationId, + overrideAccess: true, + }) + } catch { + return + } + + if (registration.registrationStatus === "cancelled") return + + await payload.update({ + collection: "social-session-registrations", + id: registration.id, + data: { registrationStatus: "cancelled" }, + overrideAccess: true, + }) +} + export async function POST(req: Request) { const body = await req.text() const sig = req.headers.get("stripe-signature") @@ -132,6 +159,8 @@ export async function POST(req: Request) { await handleCheckoutSessionExpired(event.data.object as Stripe.Checkout.Session) break case "payment_intent.payment_failed": + await handlePaymentIntentFailed(event.data.object as Stripe.PaymentIntent) + break case "charge.refunded": break default: From 4f2dae37513c054f398614b0f658dfbfff9fe7dd Mon Sep 17 00:00:00 2001 From: hajunk05 Date: Mon, 25 May 2026 22:51:00 +1200 Subject: [PATCH 7/7] feat(stripe): handle charge.refunded in webhook --- src/app/api/stripe/webhook/route.ts | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index 867ce26..48f32e9 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -108,6 +108,39 @@ async function handleCheckoutSessionExpired(session: Stripe.Checkout.Session) { }) } +async function handleChargeRefunded(charge: Stripe.Charge) { + const payload = await getPayloadClient() + + if (!charge.payment_intent) return + const paymentIntentId = + typeof charge.payment_intent === "string" ? charge.payment_intent : charge.payment_intent.id + const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId) + const registrationId = paymentIntent.metadata?.registrationId + if (!registrationId) return + + let registration: SocialSessionRegistration + try { + registration = await payload.findByID({ + collection: "social-session-registrations", + id: registrationId, + overrideAccess: true, + }) + } catch { + return + } + + if (registration.paymentStatus === "refunded") return + + // Setting registrationStatus: "cancelled" triggers handleCancellation which + // promotes the next waitlisted user. + await payload.update({ + collection: "social-session-registrations", + id: registration.id, + data: { paymentStatus: "refunded", registrationStatus: "cancelled" }, + overrideAccess: true, + }) +} + async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) { const registrationId = paymentIntent.metadata?.registrationId if (!registrationId) return @@ -162,6 +195,7 @@ export async function POST(req: Request) { await handlePaymentIntentFailed(event.data.object as Stripe.PaymentIntent) break case "charge.refunded": + await handleChargeRefunded(event.data.object as Stripe.Charge) break default: break