From a70f8a3b97d31ea50db82113a6665d9a624a061b Mon Sep 17 00:00:00 2001 From: Tobias Johansson Date: Mon, 18 Nov 2024 14:10:11 +0000 Subject: [PATCH] Merged in feat/SW-618-payment-non-happy-path (pull request #874) Feat/SW-618 payment non happy path * feat(SW-618): filter out expired saved cards * feat(SW-618): Added payment error codes and way of showing messages based on code * feat(SW-618): show error message if max retries has been reached and remove search param after showing toast * fix(SW-618): move fallback error codes * fix(SW-618): remove ref from stopping useEffect to run twice * fix(SW-618): refactored logic for toast message and minor fixes * fix(SW-618): remove error message enum due to static analysis problems Approved-by: Christian Andolf Approved-by: Arvid Norlin --- .../payment-callback/[lang]/[status]/route.ts | 39 ++++++++++++--- .../EnterDetails/Payment/index.tsx | 35 ++++++++++---- constants/booking.ts | 6 +++ hooks/booking/useHandleBookingStatus.ts | 26 +++++++--- hooks/booking/usePaymentFailedToast.ts | 48 +++++++++++++++++++ i18n/dictionaries/da.json | 3 ++ i18n/dictionaries/de.json | 3 ++ i18n/dictionaries/en.json | 3 ++ i18n/dictionaries/fi.json | 3 ++ i18n/dictionaries/no.json | 3 ++ i18n/dictionaries/sv.json | 3 ++ server/routers/booking/output.ts | 1 + server/routers/user/query.ts | 31 +++++++++--- 13 files changed, 176 insertions(+), 28 deletions(-) create mode 100644 hooks/booking/usePaymentFailedToast.ts diff --git a/app/api/web/payment-callback/[lang]/[status]/route.ts b/app/api/web/payment-callback/[lang]/[status]/route.ts index baa8e4b8a..624df1f52 100644 --- a/app/api/web/payment-callback/[lang]/[status]/route.ts +++ b/app/api/web/payment-callback/[lang]/[status]/route.ts @@ -1,11 +1,15 @@ import { NextRequest, NextResponse } from "next/server" -import { BOOKING_CONFIRMATION_NUMBER } from "@/constants/booking" +import { + BOOKING_CONFIRMATION_NUMBER, + PaymentErrorCodeEnum, +} from "@/constants/booking" import { Lang } from "@/constants/languages" import { bookingConfirmation, payment, } from "@/constants/routes/hotelReservation" +import { serverClient } from "@/lib/trpc/server" import { getPublicURL } from "@/server/utils" export async function GET( @@ -35,12 +39,35 @@ export async function GET( const returnUrl = new URL(`${publicURL}/${payment[lang]}`) returnUrl.search = queryParams.toString() - if (status === "cancel") { - returnUrl.searchParams.set("cancel", "true") - } + if (confirmationNumber) { + try { + const bookingStatus = await serverClient().booking.status({ + confirmationNumber, + }) + if (bookingStatus.metadata) { + returnUrl.searchParams.set( + "errorCode", + bookingStatus.metadata.errorCode?.toString() ?? "" + ) + } + } catch (error) { + console.error( + `[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}` + ) - if (status === "error") { - returnUrl.searchParams.set("error", "true") + if (status === "cancel") { + returnUrl.searchParams.set( + "errorCode", + PaymentErrorCodeEnum.Cancelled.toString() + ) + } + if (status === "error") { + returnUrl.searchParams.set( + "errorCode", + PaymentErrorCodeEnum.Failed.toString() + ) + } + } } console.log(`[payment-callback] redirecting to: ${returnUrl}`) diff --git a/components/HotelReservation/EnterDetails/Payment/index.tsx b/components/HotelReservation/EnterDetails/Payment/index.tsx index 84caf0967..ff402bf2d 100644 --- a/components/HotelReservation/EnterDetails/Payment/index.tsx +++ b/components/HotelReservation/EnterDetails/Payment/index.tsx @@ -28,6 +28,7 @@ import Body from "@/components/TempDesignSystem/Text/Body" import Caption from "@/components/TempDesignSystem/Text/Caption" import { toast } from "@/components/TempDesignSystem/Toasts" import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus" +import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast" import useLang from "@/hooks/useLang" import { bedTypeMap } from "../../SelectRate/RoomSelection/utils" @@ -41,7 +42,7 @@ import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectR import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section" import { BreakfastPackageEnum } from "@/types/enums/breakfast" -const maxRetries = 40 +const maxRetries = 4 const retryInterval = 2000 export const formId = "submit-booking" @@ -87,6 +88,8 @@ export default function Payment({ const [availablePaymentOptions, setAvailablePaymentOptions] = useState(otherPaymentOptions) + usePaymentFailedToast() + const methods = useForm({ defaultValues: { paymentMethod: savedCreditCards?.length @@ -105,23 +108,29 @@ export default function Payment({ if (result?.confirmationNumber) { setConfirmationNumber(result.confirmationNumber) } else { - // TODO: add proper error message - toast.error("Failed to create booking") + toast.error( + intl.formatMessage({ + id: "payment.error.failed", + }) + ) } }, onError: (error) => { console.error("Error", error) - // TODO: add proper error message - toast.error("Failed to create booking") + toast.error( + intl.formatMessage({ + id: "payment.error.failed", + }) + ) }, }) - const bookingStatus = useHandleBookingStatus( + const bookingStatus = useHandleBookingStatus({ confirmationNumber, - BookingStatusEnum.PaymentRegistered, + expectedStatus: BookingStatusEnum.BookingCompleted, maxRetries, - retryInterval - ) + retryInterval, + }) useEffect(() => { if (window.ApplePaySession) { @@ -138,8 +147,14 @@ export default function Payment({ useEffect(() => { if (bookingStatus?.data?.paymentUrl) { router.push(bookingStatus.data.paymentUrl) + } else if (bookingStatus.isTimeout) { + toast.error( + intl.formatMessage({ + id: "payment.error.failed", + }) + ) } - }, [bookingStatus, router]) + }, [bookingStatus, router, intl]) useEffect(() => { setIsSubmittingDisabled( diff --git a/constants/booking.ts b/constants/booking.ts index c99ca3d0e..76e27b0e8 100644 --- a/constants/booking.ts +++ b/constants/booking.ts @@ -46,6 +46,12 @@ export enum PaymentMethodEnum { discover = "discover", } +export enum PaymentErrorCodeEnum { + Abandoned = 5, + Cancelled = 6, + Failed = 7, +} + export const PAYMENT_METHOD_TITLES: Record< keyof typeof PaymentMethodEnum, string diff --git a/hooks/booking/useHandleBookingStatus.ts b/hooks/booking/useHandleBookingStatus.ts index 5373057a8..7c1fafcca 100644 --- a/hooks/booking/useHandleBookingStatus.ts +++ b/hooks/booking/useHandleBookingStatus.ts @@ -1,19 +1,30 @@ "use client" +import { useRef } from "react" + import { BookingStatusEnum } from "@/constants/booking" import { trpc } from "@/lib/trpc/client" -export function useHandleBookingStatus( - confirmationNumber: string | null, - expectedStatus: BookingStatusEnum, - maxRetries: number, +export function useHandleBookingStatus({ + confirmationNumber, + expectedStatus, + maxRetries, + retryInterval, +}: { + confirmationNumber: string | null + expectedStatus: BookingStatusEnum + maxRetries: number retryInterval: number -) { +}) { + const retries = useRef(0) + const query = trpc.booking.status.useQuery( { confirmationNumber: confirmationNumber ?? "" }, { enabled: !!confirmationNumber, refetchInterval: (query) => { + retries.current = query.state.dataUpdateCount + if (query.state.error || query.state.dataUpdateCount >= maxRetries) { return false } @@ -31,5 +42,8 @@ export function useHandleBookingStatus( } ) - return query + return { + ...query, + isTimeout: retries.current >= maxRetries, + } } diff --git a/hooks/booking/usePaymentFailedToast.ts b/hooks/booking/usePaymentFailedToast.ts new file mode 100644 index 000000000..cccbe2db0 --- /dev/null +++ b/hooks/booking/usePaymentFailedToast.ts @@ -0,0 +1,48 @@ +"use client" + +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { useCallback, useEffect } from "react" +import { useIntl } from "react-intl" + +import { PaymentErrorCodeEnum } from "@/constants/booking" + +import { toast } from "@/components/TempDesignSystem/Toasts" + +export function usePaymentFailedToast() { + const intl = useIntl() + const searchParams = useSearchParams() + const pathname = usePathname() + const router = useRouter() + + const getErrorMessage = useCallback( + (errorCode: PaymentErrorCodeEnum) => { + switch (errorCode) { + case PaymentErrorCodeEnum.Cancelled: + return intl.formatMessage({ id: "payment.error.cancelled" }) + default: + return intl.formatMessage({ id: "payment.error.failed" }) + } + }, + [intl] + ) + + const errorCodeString = searchParams.get("errorCode") + const errorCode = Number(errorCodeString) as PaymentErrorCodeEnum + const errorMessage = getErrorMessage(errorCode) + + useEffect(() => { + if (!errorCode) return + + // setTimeout is needed to show toasts on page load: https://sonner.emilkowal.ski/toast#render-toast-on-page-load + setTimeout(() => { + const toastType = + errorCode === PaymentErrorCodeEnum.Cancelled ? "warning" : "error" + + toast[toastType](errorMessage) + }) + + const queryParams = new URLSearchParams(searchParams.toString()) + queryParams.delete("errorCode") + router.replace(`${pathname}?${queryParams.toString()}`) + }, [searchParams, router, pathname, errorCode, errorMessage]) +} diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index aa2278b09..076544355 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -444,6 +444,9 @@ "number": "nummer", "or": "eller", "paying": "betaler ", + "payment.error.abandoned": "Vi havde et problem med at behandle din booking. Prøv venligst igen. Ingen gebyrer er blevet opkrævet.", + "payment.error.cancelled": "Du har nu annulleret din betaling.", + "payment.error.failed": "Vi havde et problem med at behandle din booking. Prøv venligst igen. Ingen gebyrer er blevet opkrævet.", "points": "Point", "room type": "værelsestype", "room types": "værelsestyper", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index 7476ad6a8..02e7da88d 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -443,6 +443,9 @@ "number": "nummer", "or": "oder", "paying": "bezahlt", + "payment.error.abandoned": "Wir hatten ein Problem beim Verarbeiten Ihrer Buchung. Bitte versuchen Sie es erneut. Es wurden keine Gebühren erhoben.", + "payment.error.cancelled": "Sie haben jetzt Ihre Zahlung abgebrochen.", + "payment.error.failed": "Wir hatten ein Problem beim Verarbeiten Ihrer Buchung. Bitte versuchen Sie es erneut. Es wurden keine Gebühren erhoben.", "points": "Punkte", "room type": "zimmerart", "room types": "zimmerarten", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index b0405ee85..2263cf43f 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -482,6 +482,9 @@ "number": "number", "or": "or", "paying": "paying", + "payment.error.abandoned": "We had an issue processing your booking. Please try again. No charges have been made.", + "payment.error.cancelled": "You have now cancelled your payment.", + "payment.error.failed": "We had an issue processing your booking. Please try again. No charges have been made.", "points": "Points", "room type": "room type", "room types": "room types", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index ebd4b861a..59097c54a 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -443,6 +443,9 @@ "number": "määrä", "or": "tai", "paying": "maksaa", + "payment.error.abandoned": "Meillä oli ongelma varauksen käsittelyssä. Yritä uudelleen. Ei maksuja on tehty.", + "payment.error.cancelled": "Sinut nyt peruutit maksun.", + "payment.error.failed": "Meillä oli ongelma varauksen käsittelyssä. Yritä uudelleen. Ei maksuja on tehty.", "points": "pistettä", "room type": "huonetyyppi", "room types": "huonetyypit", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index ff0e91da0..f5f131e8c 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -441,6 +441,9 @@ "number": "antall", "or": "eller", "paying": "betaler", + "payment.error.abandoned": "Vi hadde et problem med å behandle din bestilling. Vær så snill å prøv igjen. Ingen gebyrer er blevet belastet.", + "payment.error.cancelled": "Du har nå annullerer din betaling.", + "payment.error.failed": "Vi hadde et problem med å behandle din bestilling. Vær så snill å prøv igjen. Ingen gebyrer er blevet belastet.", "points": "poeng", "room type": "romtype", "room types": "romtyper", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index a684cd110..bca60e315 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -442,6 +442,9 @@ "number": "nummer", "or": "eller", "paying": "betalar", + "payment.error.abandoned": "Vi hade et problem med att bearbeta din bokning. Vänligen försök igen. Inga avgifter har debiterats.", + "payment.error.cancelled": "Du har nu avbrutit din betalning.", + "payment.error.failed": "Vi hade ett problem med att bearbeta din bokning. Vänligen försök igen. Inga avgifter har debiterats.", "points": "poäng", "room type": "rumtyp", "room types": "rumstyper", diff --git a/server/routers/booking/output.ts b/server/routers/booking/output.ts index 83a185e1e..ae2e14cf4 100644 --- a/server/routers/booking/output.ts +++ b/server/routers/booking/output.ts @@ -48,6 +48,7 @@ export const createBookingSchema = z cancellationNumber: d.data.attributes.cancellationNumber, reservationStatus: d.data.attributes.reservationStatus, paymentUrl: d.data.attributes.paymentUrl, + metadata: d.data.attributes.metadata, })) // QUERY diff --git a/server/routers/user/query.ts b/server/routers/user/query.ts index a0d7face0..326ca7cca 100644 --- a/server/routers/user/query.ts +++ b/server/routers/user/query.ts @@ -2,6 +2,7 @@ import { metrics } from "@opentelemetry/api" import { cache } from "react" import * as api from "@/lib/api" +import { dt } from "@/lib/dt" import { protectedProcedure, router, @@ -208,7 +209,13 @@ export function parsedUser(data: User, isMFA: boolean) { return user } -async function getCreditCards(session: Session) { +async function getCreditCards({ + session, + onlyNonExpired, +}: { + session: Session + onlyNonExpired?: boolean +}) { getCreditCardsCounter.add(1) console.info("api.profile.creditCards start", JSON.stringify({})) const apiResponse = await api.get(api.endpoints.v1.Profile.creditCards, { @@ -255,7 +262,19 @@ async function getCreditCards(session: Session) { } getCreditCardsSuccessCounter.add(1) console.info("api.profile.creditCards success", JSON.stringify({})) - return verifiedData.data.data + + return verifiedData.data.data.filter((card) => { + if (onlyNonExpired) { + try { + const expirationDate = dt(card.expirationDate).startOf("day") + const currentDate = dt().startOf("day") + return expirationDate > currentDate + } catch (error) { + return false + } + } + return true + }) } export const userQueryRouter = router({ @@ -492,7 +511,7 @@ export const userQueryRouter = router({ ) const nextCursor = verifiedData.data.links && - verifiedData.data.links.offset < verifiedData.data.links.totalCount + verifiedData.data.links.offset < verifiedData.data.links.totalCount ? verifiedData.data.links.offset : undefined @@ -585,7 +604,7 @@ export const userQueryRouter = router({ }) const nextCursor = verifiedData.data.links && - verifiedData.data.links.offset < verifiedData.data.links.totalCount + verifiedData.data.links.offset < verifiedData.data.links.totalCount ? verifiedData.data.links.offset : undefined @@ -730,14 +749,14 @@ export const userQueryRouter = router({ }), creditCards: protectedProcedure.query(async function ({ ctx }) { - return await getCreditCards(ctx.session) + return await getCreditCards({ session: ctx.session }) }), safeCreditCards: safeProtectedProcedure.query(async function ({ ctx }) { if (!ctx.session) { return null } - return await getCreditCards(ctx.session) + return await getCreditCards({ session: ctx.session, onlyNonExpired: true }) }), membershipCards: protectedProcedure.query(async function ({ ctx }) {