feat(SW-2605): confirmation page only valid for 1 minute for the session

This commit is contained in:
Michael Zetterberg
2025-05-06 12:04:00 +02:00
parent e0fe5ff0d5
commit c6a0ce86df
9 changed files with 73 additions and 21 deletions

View File

@@ -1,21 +1,41 @@
import { notFound } from "next/navigation" import { cookies } from "next/headers"
import { notFound, redirect } from "next/navigation"
import { MEMBERSHIP_FAILED_ERROR } from "@/constants/booking" import { MEMBERSHIP_FAILED_ERROR } from "@/constants/booking"
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests" import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import BookingConfirmation from "@/components/HotelReservation/BookingConfirmation" import BookingConfirmation from "@/components/HotelReservation/BookingConfirmation"
import { decrypt } from "@/utils/encryption"
import type { LangParams, PageArgs } from "@/types/params" import type { LangParams, PageArgs } from "@/types/params"
export default async function BookingConfirmationPage({ export default async function BookingConfirmationPage({
params,
searchParams, searchParams,
}: PageArgs<LangParams, { RefId?: string }>) { }: PageArgs<
LangParams,
{
RefId?: string
}
>) {
const refId = searchParams.RefId const refId = searchParams.RefId
if (!refId) { if (!refId) {
notFound() notFound()
} }
const sig = cookies().get("bcsig")?.value
if (!sig) {
redirect(`/${params.lang}`)
}
const expire = Number(decrypt(sig))
const now = Math.floor(Date.now() / 1000)
if (typeof expire === "number" && !isNaN(expire) && now > expire) {
redirect(`/${params.lang}`)
}
void getBookingConfirmation(refId) void getBookingConfirmation(refId)
const membershipFailedError = const membershipFailedError =

View File

@@ -61,7 +61,9 @@ export default async function GuaranteePaymentCallbackPage({
refId, refId,
}) })
const error = bookingStatus.errors.find((e) => e.errorCode) const { booking } = bookingStatus
const error = booking.errors.find((e) => e.errorCode)
errorMessage = errorMessage =
error?.description ?? error?.description ??
`No error message found for booking ${confirmationNumber}, status: ${status}` `No error message found for booking ${confirmationNumber}, status: ${status}`

View File

@@ -15,6 +15,7 @@ import { getServiceToken } from "@/server/tokenManager"
import { auth } from "@/auth" import { auth } from "@/auth"
import HandleErrorCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleErrorCallback" import HandleErrorCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleErrorCallback"
import HandleSuccessCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleSuccessCallback" import HandleSuccessCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleSuccessCallback"
import { encrypt } from "@/utils/encryption"
import type { LangParams, PageArgs } from "@/types/params" import type { LangParams, PageArgs } from "@/types/params"
@@ -62,6 +63,8 @@ export default async function PaymentCallbackPage({
const { refId } = booking const { refId } = booking
if (status === PaymentCallbackStatusEnum.Success && confirmationNumber) { if (status === PaymentCallbackStatusEnum.Success && confirmationNumber) {
const expire = Math.floor(Date.now() / 1000) + 60
const sig = encrypt(expire.toString())
const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(refId)}` const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(refId)}`
console.log( console.log(
`[payment-callback] rendering success callback with confirmation number: ${confirmationNumber}` `[payment-callback] rendering success callback with confirmation number: ${confirmationNumber}`
@@ -70,6 +73,7 @@ export default async function PaymentCallbackPage({
return ( return (
<HandleSuccessCallback <HandleSuccessCallback
refId={refId} refId={refId}
sig={sig}
successRedirectUrl={confirmationUrl} successRedirectUrl={confirmationUrl}
/> />
) )
@@ -86,8 +90,10 @@ export default async function PaymentCallbackPage({
refId, refId,
}) })
const { booking } = bookingStatus
// TODO: how to handle errors for multiple rooms? // TODO: how to handle errors for multiple rooms?
const error = bookingStatus.errors.find((e) => e.errorCode) const error = booking.errors.find((e) => e.errorCode)
errorMessage = errorMessage =
error?.description ?? error?.description ??

View File

@@ -19,15 +19,22 @@ const validBookingStatuses = [
interface HandleStatusPollingProps { interface HandleStatusPollingProps {
refId: string refId: string
sig: string
successRedirectUrl: string successRedirectUrl: string
} }
export default function HandleSuccessCallback({ export default function HandleSuccessCallback({
refId, refId,
sig,
successRedirectUrl, successRedirectUrl,
}: HandleStatusPollingProps) { }: HandleStatusPollingProps) {
const router = useRouter() const router = useRouter()
useEffect(() => {
// Cookie is used by Booking Confirmation page to validate that the user came from payment callback
document.cookie = `bcsig=${sig}; Path=/; Max-Age=60; Secure; SameSite=Strict`
}, [sig])
const { const {
data: bookingStatus, data: bookingStatus,
error, error,
@@ -41,13 +48,13 @@ export default function HandleSuccessCallback({
}) })
useEffect(() => { useEffect(() => {
if (!bookingStatus?.reservationStatus) { if (!bookingStatus?.booking.reservationStatus) {
return return
} }
if ( if (
validBookingStatuses.includes( validBookingStatuses.includes(
bookingStatus.reservationStatus as BookingStatusEnum bookingStatus.booking.reservationStatus as BookingStatusEnum
) )
) { ) {
const glaSessionData = readGlaFromSessionStorage() const glaSessionData = readGlaFromSessionStorage()
@@ -63,7 +70,7 @@ export default function HandleSuccessCallback({
clearGlaSessionStorage() clearGlaSessionStorage()
} }
// a successful booking can still have membership errors // a successful booking can still have membership errors
const membershipFailedError = bookingStatus.errors.find( const membershipFailedError = bookingStatus.booking.errors.find(
(e) => e.errorCode === MEMBERSHIP_FAILED_ERROR (e) => e.errorCode === MEMBERSHIP_FAILED_ERROR
) )
const errorParam = membershipFailedError const errorParam = membershipFailedError

View File

@@ -155,9 +155,12 @@ export default function PaymentClient({
return return
} }
const mainRoom = result.rooms[0] const { booking } = result
const mainRoom = booking.rooms[0]
if (result.reservationStatus == BookingStatusEnum.BookingCompleted) { if (booking.reservationStatus == BookingStatusEnum.BookingCompleted) {
// Cookie is used by Booking Confirmation page to validate that the user came from payment callback
document.cookie = `bcsig=${result.sig}; Path=/; Max-Age=60; Secure; SameSite=Strict`
const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(mainRoom.refId)}` const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(mainRoom.refId)}`
router.push(confirmationUrl) router.push(confirmationUrl)
return return
@@ -165,9 +168,9 @@ export default function PaymentClient({
setRefId(mainRoom.refId) setRefId(mainRoom.refId)
const hasPriceChange = result.rooms.some((r) => r.priceChangedMetadata) const hasPriceChange = booking.rooms.some((r) => r.priceChangedMetadata)
if (hasPriceChange) { if (hasPriceChange) {
const priceChangeData = result.rooms.map( const priceChangeData = booking.rooms.map(
(room) => room.priceChangedMetadata || null (room) => room.priceChangedMetadata || null
) )
setPriceChangeData(priceChangeData) setPriceChangeData(priceChangeData)
@@ -258,13 +261,15 @@ export default function PaymentClient({
) )
useEffect(() => { useEffect(() => {
if (bookingStatus?.data?.paymentUrl) { if (bookingStatus?.data?.booking.paymentUrl) {
router.push(bookingStatus.data.paymentUrl) router.push(bookingStatus.data.booking.paymentUrl)
} else if ( } else if (
bookingStatus?.data?.reservationStatus === bookingStatus?.data?.booking.reservationStatus ===
BookingStatusEnum.BookingCompleted BookingStatusEnum.BookingCompleted
) { ) {
const mainRoom = bookingStatus.data.rooms[0] const mainRoom = bookingStatus.data.booking.rooms[0]
// Cookie is used by Booking Confirmation page to validate that the user came from payment callback
document.cookie = `bcsig=${bookingStatus.data.sig}; Path=/; Max-Age=60; Secure; SameSite=Strict`
const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(mainRoom.refId)}` const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(mainRoom.refId)}`
router.push(confirmationUrl) router.push(confirmationUrl)
} else if (bookingStatus.isTimeout) { } else if (bookingStatus.isTimeout) {

View File

@@ -75,8 +75,8 @@ export function useGuaranteeBooking(
}) })
useEffect(() => { useEffect(() => {
if (bookingStatus?.data?.paymentUrl && isPollingForBookingStatus) { if (bookingStatus?.data?.booking.paymentUrl && isPollingForBookingStatus) {
router.push(bookingStatus.data.paymentUrl) router.push(bookingStatus.data.booking.paymentUrl)
utils.booking.get.invalidate({ refId }) utils.booking.get.invalidate({ refId })
setIsPollingForBookingStatus(false) setIsPollingForBookingStatus(false)
} else if (bookingStatus.isTimeout) { } else if (bookingStatus.isTimeout) {
@@ -95,7 +95,7 @@ export function useGuaranteeBooking(
const isLoading = const isLoading =
guaranteeBooking.isPending || guaranteeBooking.isPending ||
(isPollingForBookingStatus && (isPollingForBookingStatus &&
!bookingStatus.data?.paymentUrl && !bookingStatus.data?.booking.paymentUrl &&
!bookingStatus.isTimeout) !bookingStatus.isTimeout)
return { return {

View File

@@ -34,7 +34,7 @@ export function useHandleBookingStatus({
if ( if (
expectedStatuses.includes( expectedStatuses.includes(
query.state.data?.reservationStatus as BookingStatusEnum query.state.data?.booking.reservationStatus as BookingStatusEnum
) )
) { ) {
return false return false

View File

@@ -4,6 +4,8 @@ import { getMembershipNumber } from "@/server/routers/user/utils"
import { createCounter } from "@/server/telemetry" import { createCounter } from "@/server/telemetry"
import { router, safeProtectedServiceProcedure } from "@/server/trpc" import { router, safeProtectedServiceProcedure } from "@/server/trpc"
import { encrypt } from "@/utils/encryption"
import { import {
addPackageInput, addPackageInput,
cancelBookingsInput, cancelBookingsInput,
@@ -71,7 +73,12 @@ export const bookingMutationRouter = router({
metricsCreateBooking.success() metricsCreateBooking.success()
return verifiedData.data const expire = Math.floor(Date.now() / 1000) + 60 // 1 minute expiry
return {
booking: verifiedData.data,
sig: encrypt(expire.toString()),
}
}), }),
priceChange: safeProtectedServiceProcedure priceChange: safeProtectedServiceProcedure
.concat(refIdPlugin.toConfirmationNumber) .concat(refIdPlugin.toConfirmationNumber)

View File

@@ -244,7 +244,12 @@ export const bookingQueryRouter = router({
metricsGetBookingStatus.success() metricsGetBookingStatus.success()
return verifiedData.data const expire = Math.floor(Date.now() / 1000) + 60 // 1 minute expiry
return {
booking: verifiedData.data,
sig: encrypt(expire.toString()),
}
}), }),
createRefId: serviceProcedure createRefId: serviceProcedure
.input(createRefIdInput) .input(createRefIdInput)