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
This commit is contained in:
Tobias Johansson
2024-11-18 14:10:11 +00:00
parent d18bc45b19
commit a70f8a3b97
13 changed files with 176 additions and 28 deletions

View File

@@ -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}`)

View File

@@ -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<PaymentFormData>({
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(

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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])
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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 }) {