diff --git a/apps/blade/src/app/_components/dashboard/member-dashboard/payment/payment-button.tsx b/apps/blade/src/app/_components/dashboard/member-dashboard/payment/payment-button.tsx index f935a1759..d53e6b196 100644 --- a/apps/blade/src/app/_components/dashboard/member-dashboard/payment/payment-button.tsx +++ b/apps/blade/src/app/_components/dashboard/member-dashboard/payment/payment-button.tsx @@ -6,13 +6,8 @@ import { useRouter } from "next/navigation"; import type { InsertMember } from "@forge/db/schemas/knight-hacks"; import { Button } from "@forge/ui/button"; -import { api } from "~/trpc/react"; - export default function PaymentButton({ member }: { member: InsertMember }) { - const { mutateAsync: createCheckoutUrl } = - api.duesPayment.createCheckout.useMutation(); const router = useRouter(); - const [disableButton, setDisableButton] = useState(false); useEffect(() => { @@ -33,15 +28,9 @@ export default function PaymentButton({ member }: { member: InsertMember }) { } }, [member.school]); - const handleCheckout = async () => { - const { checkoutUrl } = await createCheckoutUrl(); - if (checkoutUrl) { - router.push(checkoutUrl); - } - }; return ( + + + + ); +} + +export function CheckoutForm() { + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + const [clientSecret, setClientSecret] = useState(null); + const [intentError, setIntentError] = useState(null); + + const { mutate: createPaymentIntent } = + api.duesPayment.createPaymentIntent.useMutation({ + onSuccess: (data) => { + if (data.clientSecret) { + setClientSecret(data.clientSecret); + } else { + setIntentError("Could not initialize payment. Please try again."); + } + }, + onError: (error) => { + setIntentError(error.message); + toast.error("Failed to start checkout."); + }, + }); + + useEffect(() => { + createPaymentIntent(); + }, [createPaymentIntent]); + + return ( +
+
+ {/* Left — order summary */} +
+
+

+ Knight Hacks +

+

$25.00

+

+ Club Membership +

+
+
+
+ + Knight Hacks Membership + + $25.00 +
+
+ Total due today + $25.00 +
+
+
+ + {/* Right — payment form */} +
+ {intentError && ( +

{intentError}

+ )} + + {!clientSecret && !intentError && ( +
+
+
+
+
+ )} + + {clientSecret && ( + + + + )} +
+
+
+ ); +} diff --git a/apps/blade/src/app/_components/dashboard/member/membership-success-page.tsx b/apps/blade/src/app/_components/dashboard/member/membership-success-page.tsx index d69c93020..a2d1cb57a 100644 --- a/apps/blade/src/app/_components/dashboard/member/membership-success-page.tsx +++ b/apps/blade/src/app/_components/dashboard/member/membership-success-page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect } from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -17,10 +18,23 @@ export function MembershipSuccess() { const searchParams = useSearchParams(); const router = useRouter(); - const checkoutSessionId = searchParams.get("session_id") ?? ""; + const paymentIntentId = searchParams.get("payment_intent"); - const { data, isPending, isError } = - api.duesPayment.orderSuccess.useQuery(checkoutSessionId); + const { data, isPending, isError } = api.duesPayment.orderSuccess.useQuery( + paymentIntentId ?? "", + { enabled: Boolean(paymentIntentId) }, + ); + + useEffect(() => { + if (!paymentIntentId) { + toast.error("Invalid confirmation link."); + router.replace(SIGN_IN_PATH); + } + }, [paymentIntentId, router]); + + if (!paymentIntentId) { + return null; + } if (isError) { toast.error("Something went wrong, please contact support."); @@ -32,10 +46,27 @@ export function MembershipSuccess() { return ; } - if (data.status === "unpaid") { - toast.error("Checkout session was not complete, please try again."); - router.push(SIGN_IN_PATH); - return null; + if (data.status === "processing") { + return ( +
+
+
+

Payment Processing

+

+ Your payment is being processed. You will receive a confirmation + email once it is complete. +

+
+ + Pending + + Bank transfers can take 1–5 business days to settle. Your + membership will be activated once the payment clears. + + +
+
+ ); } return ( diff --git a/apps/blade/src/app/api/membership/route.ts b/apps/blade/src/app/api/membership/route.ts index 57e957e59..052c9f6cf 100644 --- a/apps/blade/src/app/api/membership/route.ts +++ b/apps/blade/src/app/api/membership/route.ts @@ -38,7 +38,10 @@ async function membershipRecord(sessionId: string) { // Check the Checkout Session's payment_status property // to determine if fulfillment should be peformed if (checkoutSession.payment_status !== "unpaid") { - await db.insert(DuesPayment).values({ ...validatedCheckoutFields.data }); + await db + .insert(DuesPayment) + .values({ ...validatedCheckoutFields.data }) + .onConflictDoNothing(); return true; } throw new Error("Checkout session payment status is unpaid"); @@ -48,6 +51,41 @@ async function membershipRecord(sessionId: string) { } } +async function fulfillPaymentIntent(paymentIntentId: string) { + const stripe = new Stripe(env.STRIPE_SECRET_KEY, { typescript: true }); + + try { + const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId); + + if (paymentIntent.status !== "succeeded") { + throw new Error("Payment intent has not succeeded"); + } + + const memberId = paymentIntent.metadata.member_id; + + const values = { + memberId, + amount: paymentIntent.amount, + paymentDate: new Date(paymentIntent.created * 1000), + year: new Date().getFullYear(), + }; + + const validated = DuesPaymentSchema.omit({ id: true }).safeParse(values); + + if (!validated.success) { + logger.log(validated.error.issues); + throw new Error("Invalid or missing field(s)"); + } + + await db.insert(DuesPayment).values(validated.data).onConflictDoNothing(); + + return true; + } catch (e) { + logger.error("Error:", e); + return false; + } +} + export async function POST(request: NextRequest) { const sig = request.headers.get("stripe-signature") ?? ""; const stripe = new Stripe(env.STRIPE_SECRET_KEY); @@ -80,6 +118,21 @@ export async function POST(request: NextRequest) { return new Response("Checkout session expired", { status: 408, }); + case "payment_intent.succeeded": + success = await fulfillPaymentIntent(event.data.object.id); + break; + case "payment_intent.payment_failed": + logger.warn("Payment failed", { paymentIntentId: event.data.object.id }); + return new Response("Event received", { + status: 200, + }); + case "payment_intent.canceled": + logger.warn("Payment canceled", { + paymentIntentId: event.data.object.id, + }); + return new Response("Event received", { + status: 200, + }); default: return new Response(`Unhandled event type ${event.type}`, { status: 202, diff --git a/apps/blade/src/app/member/checkout/page.tsx b/apps/blade/src/app/member/checkout/page.tsx new file mode 100644 index 000000000..25ea76e3d --- /dev/null +++ b/apps/blade/src/app/member/checkout/page.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { auth } from "@forge/auth"; + +import { CheckoutForm } from "~/app/_components/dashboard/member/checkout-form"; +import { SessionNavbar } from "~/app/_components/navigation/session-navbar"; +import { api, HydrateClient } from "~/trpc/server"; + +export const metadata: Metadata = { + title: "Blade | Pay Dues", + description: "Pay your Knight Hacks membership dues.", +}; + +export default async function CheckoutPage() { + const session = await auth(); + + if (!session) { + redirect("/"); + } + + const dues = await api.duesPayment.validatePaidDues(); + if (dues.duesPaid) { + redirect("/dashboard"); + } + + return ( + + +
+ +
+
+ ); +} diff --git a/packages/api/src/routers/dues-payment.ts b/packages/api/src/routers/dues-payment.ts index 2ea377709..968ae8895 100644 --- a/packages/api/src/routers/dues-payment.ts +++ b/packages/api/src/routers/dues-payment.ts @@ -1,10 +1,9 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { TRPCError } from "@trpc/server"; -import Stripe from "stripe"; import { z } from "zod"; import { CLUB } from "@forge/consts"; -import { eq } from "@forge/db"; +import { and, eq } from "@forge/db"; import { db } from "@forge/db/client"; import { DuesPayment, Member } from "@forge/db/schemas/knight-hacks"; import { permissions } from "@forge/utils"; @@ -62,6 +61,49 @@ export const duesPaymentRouter = { return { checkoutUrl: session.url }; }), + createPaymentIntent: protectedProcedure.mutation(async ({ ctx }) => { + const price = CLUB.MEMBERSHIP_PRICE as number; + const member = await db + .select() + .from(Member) + .where(eq(Member.userId, ctx.session.user.id)) + .limit(1); + if (member.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: + "User is not a member of Knight Hacks, please sign up and try again.", + }); + } + const memberId = member[0]?.id ?? ""; + const billingYear = new Date().getFullYear(); + const existingDues = await db + .select({ id: DuesPayment.id }) + .from(DuesPayment) + .where( + and( + eq(DuesPayment.memberId, memberId), + eq(DuesPayment.year, billingYear), + ), + ) + .limit(1); + if (existingDues.length > 0) { + throw new TRPCError({ + code: "CONFLICT", + message: "Membership dues have already been paid.", + }); + } + const paymentIntent = await stripe.paymentIntents.create({ + amount: price, + currency: "usd", + payment_method_types: ["card", "us_bank_account"], + metadata: { + member_id: memberId, + }, + }); + return { clientSecret: paymentIntent.client_secret }; + }), + validatePaidDues: protectedProcedure.query(async ({ ctx }) => { const duesPaymentExists = await db .select() @@ -79,21 +121,74 @@ export const duesPaymentRouter = { orderSuccess: protectedProcedure .input(z.string()) .query(async ({ input, ctx }) => { - const stripe = new Stripe(env.STRIPE_SECRET_KEY); - const session = await stripe.checkout.sessions.retrieve(input); - - await discord.log({ - message: `A member has successfully paid their dues. ${session.amount_total}`, - title: "Dues Paid", - color: "success_green", - userId: ctx.session.user.discordUserId, - }); + const paymentIntent = await stripe.paymentIntents.retrieve(input); + + const terminalFailureStatuses = [ + "canceled", + "requires_payment_method", + "requires_action", + "requires_capture", + ]; + + if (terminalFailureStatuses.includes(paymentIntent.status)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Payment has not been completed.", + }); + } + + const memberId = paymentIntent.metadata.member_id ?? ""; + + // Verify the payment belongs to the authenticated user + const currentMember = await db + .select({ id: Member.id }) + .from(Member) + .where(eq(Member.userId, ctx.session.user.id)) + .limit(1); + + if (currentMember[0]?.id !== memberId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Payment does not belong to the authenticated user.", + }); + } + + if (paymentIntent.status === "processing") { + return { + id: paymentIntent.id, + total: paymentIntent.amount, + email: paymentIntent.receipt_email, + status: paymentIntent.status, + }; + } + + const billingYear = new Date().getFullYear(); + + const inserted = await db + .insert(DuesPayment) + .values({ + memberId, + amount: paymentIntent.amount, + paymentDate: new Date(paymentIntent.created * 1000), + year: billingYear, + }) + .onConflictDoNothing() + .returning({ id: DuesPayment.id }); + + if (inserted.length > 0) { + await discord.log({ + message: `A member has successfully paid their dues. ${paymentIntent.amount}`, + title: "Dues Paid", + color: "success_green", + userId: ctx.session.user.discordUserId, + }); + } return { - id: session.id, - total: session.amount_total, - email: session.customer_details?.email, - status: session.payment_status, + id: paymentIntent.id, + total: paymentIntent.amount, + email: paymentIntent.receipt_email, + status: paymentIntent.status, }; }), diff --git a/packages/db/src/schemas/knight-hacks.ts b/packages/db/src/schemas/knight-hacks.ts index 434c4727e..b7439b97b 100644 --- a/packages/db/src/schemas/knight-hacks.ts +++ b/packages/db/src/schemas/knight-hacks.ts @@ -286,18 +286,24 @@ export const HackerEventAttendee = createTable( export const InsertEventAttendeeSchema = createInsertSchema(EventAttendee); export const InsertHackerAttendeeSchema = createInsertSchema(HackerAttendee); -export const DuesPayment = createTable("dues_payment", (t) => ({ - id: t.uuid().notNull().primaryKey().defaultRandom(), - memberId: t - .uuid() - .notNull() - .references(() => Member.id, { - onDelete: "cascade", - }), - amount: t.integer().notNull(), - paymentDate: t.timestamp().notNull(), - year: t.integer().notNull(), -})); +export const DuesPayment = createTable( + "dues_payment", + (t) => ({ + id: t.uuid().notNull().primaryKey().defaultRandom(), + memberId: t + .uuid() + .notNull() + .references(() => Member.id, { + onDelete: "cascade", + }), + amount: t.integer().notNull(), + paymentDate: t.timestamp().notNull(), + year: t.integer().notNull(), + }), + (table) => ({ + uniqueMemberYear: unique().on(table.memberId, table.year), + }), +); export const DuesPaymentSchema = createInsertSchema(DuesPayment);