From e53ed8eaa0832320d6f0abbcaa4fbb9e7aa7c077 Mon Sep 17 00:00:00 2001 From: Elliot Saha Date: Thu, 21 Mar 2024 22:08:15 -0700 Subject: [PATCH] feat(payment): created proper routes to pay using stripe --- src/app/(pages)/events/detail/[id]/page.tsx | 61 +++++++- src/app/(pages)/events/page.tsx | 135 ++++++++++++------ src/app/api/events/create/route.ts | 4 +- src/app/api/events/detail/route.ts | 41 +++++- .../events/ticket/expire-reservation/route.ts | 54 +++++++ src/app/api/events/ticket/purchase/route.ts | 92 ++++++++++-- .../events/ticket/purchase/success/route.ts | 98 +++++++++++++ src/models/AttendeeList.ts | 27 ++-- src/types/event.ts | 5 +- 9 files changed, 440 insertions(+), 77 deletions(-) create mode 100644 src/app/api/events/ticket/expire-reservation/route.ts diff --git a/src/app/(pages)/events/detail/[id]/page.tsx b/src/app/(pages)/events/detail/[id]/page.tsx index 5f0a377..0f7f2e4 100644 --- a/src/app/(pages)/events/detail/[id]/page.tsx +++ b/src/app/(pages)/events/detail/[id]/page.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Box, + useToast, Container, Button, Text, @@ -19,15 +20,58 @@ import { TennisEvent } from "@types"; import { useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { format, parseISO } from "date-fns"; +import { useSearchParams } from "next/navigation"; import { LocationPinIcon, ClockIcon, SingleUserIcon, UserFriendsIcon, } from "@icons"; +import { getClientSession } from "@utils"; const EventDetail = ({ params }: { params: { id: string } }) => { const router = useRouter(); + const statusToast = useToast(); + const searchParams = useSearchParams(); + + const soldOut = searchParams.get("sold-out"); + const unsuccessfulPayment = searchParams.get("unsuccessful-payment"); + const successfulPayment = searchParams.get("successful-payment"); + const purchased = searchParams.get("purchased"); + + useEffect(() => { + if (soldOut === "true") { + statusToast({ + id: "sold_out", + title: "Unfortunately, this event just sold out.", + status: "error", + }); + } + + if (purchased === "true") { + statusToast({ + id: "purchased", + title: "You have already purchased this ticket.", + status: "error", + }); + } + + if (unsuccessfulPayment === "true") { + statusToast({ + id: "unsuccessful_payment", + title: "An error has occurred and you will be issued a refund.", + status: "error", + }); + } + + if (successfulPayment === "true") { + statusToast({ + id: "successful_payment", + title: "Successfully purchased ticket.", + status: "success", + }); + } + }, [soldOut, statusToast, unsuccessfulPayment, successfulPayment, purchased]); const getEvent = async () => { const event = await axios.post( @@ -53,8 +97,13 @@ const EventDetail = ({ params }: { params: { id: string } }) => { const [purchaseLoading, setPurchaseLoading] = useState(false); const purchaseTicket = async () => { + const session = await getClientSession(); try { setPurchaseLoading(true); + + if (!session) { + router.push(`/login/?redirect=/events/detail/${params.id}`); + } const res = await axios.post( `${process.env.NEXT_PUBLIC_HOSTNAME}/api/events/ticket/purchase`, { event_id: params.id }, @@ -87,8 +136,16 @@ const EventDetail = ({ params }: { params: { id: string } }) => { colorScheme="brand" onClick={purchaseTicket} isLoading={purchaseLoading} + loadingText="Reserving Ticket" + isDisabled={ + (!data.reserved && data.available_tickets <= 0) || data.purchased + } > - Buy Ticket + {data.purchased + ? "Purchased Ticket" + : !data.reserved && data.available_tickets <= 0 + ? "Sold out" + : "Buy Ticket"} ); diff --git a/src/app/(pages)/events/page.tsx b/src/app/(pages)/events/page.tsx index ac94848..77d11fd 100644 --- a/src/app/(pages)/events/page.tsx +++ b/src/app/(pages)/events/page.tsx @@ -1,6 +1,8 @@ "use client"; import { + useToast, Container, + Box, Img, Text, Heading, @@ -15,6 +17,7 @@ import { Button, Avatar, AvatarGroup, + Badge, } from "@chakra-ui/react"; import { useQuery } from "@tanstack/react-query"; import axios from "axios"; @@ -28,6 +31,8 @@ import { UserFriendsIcon, } from "@icons"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useEffect } from "react"; // get all events const getEvents = async () => { @@ -43,6 +48,20 @@ const Events = () => { queryFn: getEvents, }); + const searchParams = useSearchParams(); + const unsuccessfulPayment = searchParams.get("unsuccessful-payment"); + + const statusToast = useToast(); + + useEffect(() => { + if (unsuccessfulPayment === "true") { + statusToast({ + id: "unsuccessful_payment", + title: "An error has occurred and you were not charged", + status: "error", + }); + } + }, [unsuccessfulPayment, statusToast]); return ( @@ -81,6 +100,7 @@ const Events = () => { {data.map((i, idx) => ( { - - - - Attendees: + {i.opening_status === "Open" && ( + + + + Attendees: + + + + + + + { /> - - - - - - - + )} - + {i.opening_status === "Open" ? ( + + ) : ( + + + OPENING SOON + + + )} ))} diff --git a/src/app/api/events/create/route.ts b/src/app/api/events/create/route.ts index 1d5bf99..081bc76 100644 --- a/src/app/api/events/create/route.ts +++ b/src/app/api/events/create/route.ts @@ -43,13 +43,15 @@ export const POST = async (request: NextRequest) => { event_id: res.id, event_name: res.name, ticket_price: res.ticket_price, - available_tickets: res.initial_tickets, + available_tickets: res.available_tickets, reserved_tickets: [], attendees: [], + reservation_expire_tasks: [], }); return ServerResponse.success(attendeeList); } catch (e) { + console.log(e); logger.error(e); return ServerResponse.serverError(e); } diff --git a/src/app/api/events/detail/route.ts b/src/app/api/events/detail/route.ts index 85d7611..4234c5b 100644 --- a/src/app/api/events/detail/route.ts +++ b/src/app/api/events/detail/route.ts @@ -1,15 +1,18 @@ -import { contentfulClient } from "@lib"; -import { ServerResponse } from "@helpers"; +import { contentfulClient, connectToDatabase } from "@lib"; +import { ServerResponse, getSession } from "@helpers"; import { TypeEventSkeleton } from "@types"; import { Asset } from "contentful"; import z from "zod"; import { NextRequest } from "next/server"; +import { AttendeeList } from "@models"; const detailSchema = z.object({ id: z.string({ required_error: "Event ID is required" }), }); export const POST = async (request: NextRequest) => { + await connectToDatabase(); + try { const { id } = await request.json(); @@ -22,6 +25,35 @@ export const POST = async (request: NextRequest) => { return ServerResponse.userError("Invalid event ID"); } + const attendeeList = await AttendeeList.findOne({ + event_id: res.sys.id, + }); + + let availableTickets = res.fields.amountOfTickets; + + if (attendeeList) { + availableTickets = attendeeList?.available_tickets; + } + + let reserved = false; + let purchased = false; + + const { session } = await getSession(request); + + if (session && attendeeList) { + if ( + attendeeList.reservation_expire_tasks.find( + (e) => e.user_id === session.user.userId, + ) + ) { + reserved = true; + } + + if (attendeeList.attendees.includes(session.user.userId)) { + purchased = true; + } + } + const event = { id: res.sys.id, name: res.fields.name, @@ -31,7 +63,9 @@ export const POST = async (request: NextRequest) => { cover_image: `https:${(res.fields.coverImage as Asset)?.fields?.file ?.url}`, description: res.fields.description, - initial_tickets: res.fields.amountOfTickets, + available_tickets: availableTickets, + reserved, + purchased, }; return ServerResponse.success(event); @@ -39,6 +73,7 @@ export const POST = async (request: NextRequest) => { return ServerResponse.userError("Invalid event ID"); } } catch (e) { + console.log(e); return ServerResponse.userError("Event not found"); } }; diff --git a/src/app/api/events/ticket/expire-reservation/route.ts b/src/app/api/events/ticket/expire-reservation/route.ts new file mode 100644 index 0000000..525df18 --- /dev/null +++ b/src/app/api/events/ticket/expire-reservation/route.ts @@ -0,0 +1,54 @@ +import { connectToDatabase, logger } from "@lib"; +import { NextRequest } from "next/server"; +import z from "zod"; +import { ServerResponse } from "@helpers"; +import { AttendeeList } from "@models/AttendeeList"; + +const expireReservationSchema = z.object({ + reservation_secret: z.string({ + required_error: "Reservation secret is required", + }), + event_id: z.string({ required_error: "Event ID is required" }), + user_id: z.string({ required_error: "User ID to expire is required" }), +}); + +export const POST = async (request: NextRequest) => { + await connectToDatabase(); + + const body = await request.json(); + + const validation = expireReservationSchema.safeParse(body); + + try { + if (validation.success) { + if (body.reservation_secret !== process.env.NEXT_RESERVATION_SECRET) { + return ServerResponse.userError("Invalid reservation secret"); + } + + const attendeeList = await AttendeeList.findOneAndUpdate( + { + event_id: body.event_id, + reserved_tickets: { $in: [body.user_id] }, + }, + { + $pull: { + reserved_tickets: body.user_id, + reservation_expire_tasks: { user_id: body.user_id }, + }, + $inc: { available_tickets: 1 }, + }, + ); + + if (!attendeeList) { + return ServerResponse.success("No user to expire"); + } + + return ServerResponse.success("Expired user"); + } else { + return ServerResponse.userError("Invalid schema"); + } + } catch (e) { + logger.error(e); + return ServerResponse.serverError(e); + } +}; diff --git a/src/app/api/events/ticket/purchase/route.ts b/src/app/api/events/ticket/purchase/route.ts index 25e5643..b8e07b7 100644 --- a/src/app/api/events/ticket/purchase/route.ts +++ b/src/app/api/events/ticket/purchase/route.ts @@ -4,6 +4,9 @@ import { NextRequest } from "next/server"; import z from "zod"; import { getSession, ServerResponse } from "@helpers"; import { TennisEvent } from "@types"; +import { logger, mergent } from "@lib"; +import { AttendeeList } from "@models/AttendeeList"; +import { addMinutes, getUnixTime } from "date-fns"; const purchaseSchema = z.object({ event_id: z.string({ required_error: "Event token is required" }), @@ -18,13 +21,11 @@ export const POST = async (request: NextRequest) => { const { session } = await getSession(request); - const url = new URL(request.url); - - const pathname = url.pathname; + const eventURLPath = `/events/detail/${event_id}`; if (!session) { return ServerResponse.success({ - url: `${process.env.NEXT_PUBLIC_HOSTNAME}/login?redirect=${pathname}`, + url: `${process.env.NEXT_PUBLIC_HOSTNAME}/login?redirect=${eventURLPath}`, }); } @@ -37,7 +38,78 @@ export const POST = async (request: NextRequest) => { }, ); - const session = await stripe.checkout.sessions.create({ + const attendeeList = await AttendeeList.findOne({ + event_id: event.data.id, + }); + + if (attendeeList.attendees.includes(session.user.userId)) { + return ServerResponse.success({ + url: `${process.env.NEXT_PUBLIC_HOSTNAME}${eventURLPath}?purchased=true`, + }); + } + + if (!attendeeList.reserved_tickets.includes(session.user.userId)) { + if (attendeeList.available_tickets <= 0) { + return ServerResponse.success({ + url: `${process.env.NEXT_PUBLIC_HOSTNAME}${eventURLPath}?sold-out=true`, + }); + } else { + await AttendeeList.findOneAndUpdate( + { + event_id: event.data.id, + }, + { + $push: { + reserved_tickets: session.user.userId, + }, + $inc: { available_tickets: -1 }, + }, + ); + + const mergentTask = await mergent.tasks.create({ + request: { + url: `${process.env.NEXT_PUBLIC_HOSTNAME}/api/events/ticket/expire-reservation`, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + reservation_secret: process.env.NEXT_RESERVATION_SECRET, + event_id: event.data.id, + user_id: session.user.userId, + }), + }, + scheduledFor: addMinutes(new Date(), 30), + }); + + await AttendeeList.findOneAndUpdate( + { + event_id: event.data.id, + }, + { + $push: { + reservation_expire_tasks: { + user_id: session.user.userId, + task_id: mergentTask.id, + }, + }, + }, + ); + } + } else { + const dBexistingTask = attendeeList.reservation_expire_tasks.find( + (e) => e.user_id === session.user.userId, + ); + + const task = await mergent.tasks.retrieve(dBexistingTask.task_id); + + if (task.status === "queued") { + await mergent.tasks.update(dBexistingTask.task_id, { + scheduledFor: addMinutes(new Date(), 30), + }); + } + } + + const expires = getUnixTime(addMinutes(new Date(), 30)); + + const stripeSession = await stripe.checkout.sessions.create({ line_items: [ { price_data: { @@ -52,17 +124,19 @@ export const POST = async (request: NextRequest) => { ], mode: "payment", metadata: { - event_id, + event_id: event.data.id, + user_id: session.user.userId, }, - //TODO: MAKE THESE ROUTES - success_url: `${process.env.NEXT_PUBLIC_HOSTNAME}/api/ticket/purchase/success`, + success_url: `${process.env.NEXT_PUBLIC_HOSTNAME}/api/events/ticket/purchase/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.NEXT_PUBLIC_HOSTNAME}/events/detail/${event_id}`, automatic_tax: { enabled: true }, + expires_at: expires, }); - return ServerResponse.success({ url: session.url }); + return ServerResponse.success({ url: stripeSession.url }); } catch (e) { console.log(e); + logger.error(e); return ServerResponse.serverError(); } } diff --git a/src/app/api/events/ticket/purchase/success/route.ts b/src/app/api/events/ticket/purchase/success/route.ts index e69de29..4b80b37 100644 --- a/src/app/api/events/ticket/purchase/success/route.ts +++ b/src/app/api/events/ticket/purchase/success/route.ts @@ -0,0 +1,98 @@ +import { connectToDatabase, stripe, logger, mergent } from "@lib"; +import { NextRequest, NextResponse } from "next/server"; +import { AttendeeList } from "@models/AttendeeList"; + +export const GET = async (request: NextRequest) => { + await connectToDatabase(); + + const sessionId = request.nextUrl.searchParams.get("session_id"); + + const session = await stripe.checkout.sessions.retrieve(sessionId); + + if ( + session && + session.status === "complete" && + session.payment_status === "paid" + ) { + const { event_id, user_id } = session.metadata; + + const attendeeList = await AttendeeList.findOne({ + event_id, + }); + + // standard case where user is in reserved, + // if the user is reserved, it means that the mergent task + // has not processed yet + if (attendeeList.reserved_tickets.includes(user_id)) { + const task = attendeeList.reservation_expire_tasks.find( + (e) => e.user_id === user_id, + ); + + await mergent.tasks.delete(task.task_id); + + await AttendeeList.findOneAndUpdate( + { + event_id, + }, + { + $pull: { + reserved_tickets: user_id, + reservation_expire_tasks: { user_id }, + }, + $push: { + attendees: user_id, + }, + }, + ); + + return NextResponse.redirect( + new URL( + `${process.env.NEXT_PUBLIC_HOSTNAME}/events/detail/${event_id}?successful_payment=true`, + ), + ); + } else { + // mergent task could theoretically occur right after payment but right before + // success api route is called meaning that user is not reserved but has paid + + // in the case where the event still has space + + if (attendeeList.available_tickets >= 1) { + await AttendeeList.findOneAndUpdate( + { event_id }, + { + $push: { + attendees: user_id, + }, + $inc: { available_tickets: -1 }, + }, + ); + + return NextResponse.redirect( + new URL( + `${process.env.NEXT_PUBLIC_HOSTNAME}/events/detail/${event_id}?successful-payment=true`, + ), + ); + } else { + // worst edge case where user pays for last ticket at last possible second before session closes, + // it gets unreserved as the user takes too long, and someone else buys before their payment gets processed + const refund = await stripe.refunds.create({ + payment_intent: session.payment_intent as string, + }); + + logger.info(refund); + + return NextResponse.redirect( + new URL( + `${process.env.NEXT_PUBLIC_HOSTNAME}/events/detail/${event_id}?unsuccessful-payment=true`, + ), + ); + } + } + } + + return NextResponse.redirect( + new URL( + `${process.env.NEXT_PUBLIC_HOSTNAME}/events?unsuccessful-payment=true`, + ), + ); +}; diff --git a/src/models/AttendeeList.ts b/src/models/AttendeeList.ts index 5d219b7..919cad6 100644 --- a/src/models/AttendeeList.ts +++ b/src/models/AttendeeList.ts @@ -1,10 +1,5 @@ import mongoose, { Schema } from "mongoose"; -export interface TicketReservation { - user_id: string; - expires: string; -} - export type UserId = string; export interface AttendeeList { @@ -13,8 +8,12 @@ export interface AttendeeList { event_name: string; ticket_price: number; available_tickets: number; - reserved_tickets: Array; + reserved_tickets: Array; attendees: Array; + reservation_expire_tasks: Array<{ + user_id: string; + task_id: string; + }>; } mongoose.Promise = global.Promise; @@ -24,17 +23,15 @@ const schema = new Schema({ event_name: { type: String, required: true }, ticket_price: { type: Number, required: true }, available_tickets: { type: Number, required: true }, - reserved_tickets: { - type: [ - { - user_id: String, - expires: String, - }, - ], - default: [], - }, // list of user_ids + reserved_tickets: [{ type: String, required: true }], attendees: [{ type: String, required: true }], + reservation_expire_tasks: [ + { + user_id: { type: String }, + task_id: { type: String }, + }, + ], }); export const AttendeeList = diff --git a/src/types/event.ts b/src/types/event.ts index 6aeed41..59224e8 100644 --- a/src/types/event.ts +++ b/src/types/event.ts @@ -6,5 +6,8 @@ export interface TennisEvent { date: string; cover_image: string; description: string; - initial_tickets: number; + available_tickets: number; + opening_status: string; + reserved: boolean; + purchased: boolean; }