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:
@@ -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}`)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
48
hooks/booking/usePaymentFailedToast.ts
Normal file
48
hooks/booking/usePaymentFailedToast.ts
Normal 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])
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user