Merged in feat/sw-3642-inject-sas-eb-payment (pull request #3243)

feat(SW-3642): Enable SAS EB payments

* Wip add SAS eb payment

* Add validate payment call

* Check booking status payment method to determine validation

* Clean up getPaymentData

* Fix PartnerPoints casing

* Add comment for validatePartnerPayment error handling

* Remove comment


Approved-by: Joakim Jäderberg
This commit is contained in:
Anton Gunnarsson
2025-12-11 13:23:12 +00:00
parent eb90c382b8
commit 7faa9933a2
19 changed files with 489 additions and 110 deletions

View File

@@ -37,7 +37,7 @@ export default async function SelectHotelPage(props: PageArgs<LangParams>) {
searchParams={searchParams} searchParams={searchParams}
config={bookingFlowConfig} config={bookingFlowConfig}
topSlot={ topSlot={
bookingFlowConfig.redemptionEnabled ? ( bookingFlowConfig.redemptionType !== "disabled" ? (
<Alert <Alert
heading={intl.formatMessage({ heading={intl.formatMessage({
id: "selectHotel.earnEuroBonusPointsAlert.heading", id: "selectHotel.earnEuroBonusPointsAlert.heading",

View File

@@ -52,7 +52,7 @@ export default async function Home(props: PageArgs<LangParams>) {
config={bookingFlowConfig} config={bookingFlowConfig}
/> />
</section> </section>
{bookingFlowConfig.redemptionEnabled && ( {bookingFlowConfig.redemptionType !== "disabled" && (
<section className={styles.infoBoxes}> <section className={styles.infoBoxes}>
<InfoBox <InfoBox
heading={intl.formatMessage({ heading={intl.formatMessage({

View File

@@ -13,7 +13,7 @@ import type { LangRoute } from "@scandic-hotels/common/constants/routes/langRout
export const bookingFlowConfig: BookingFlowConfig = { export const bookingFlowConfig: BookingFlowConfig = {
bookingCodeEnabled: false, bookingCodeEnabled: false,
savedCreditCardsEnabled: false, savedCreditCardsEnabled: false,
redemptionEnabled: env.REDEMPTION_ENABLED === true, redemptionType: env.REDEMPTION_ENABLED === true ? "partner" : "disabled",
enterDetailsMembershipIdInputLocation: "join-card", enterDetailsMembershipIdInputLocation: "join-card",
variant: "partner-sas", variant: "partner-sas",
routes: { routes: {

View File

@@ -8,7 +8,7 @@ import type { BookingFlowConfig } from "@scandic-hotels/booking-flow/BookingFlow
export const bookingFlowConfig: BookingFlowConfig = { export const bookingFlowConfig: BookingFlowConfig = {
bookingCodeEnabled: true, bookingCodeEnabled: true,
redemptionEnabled: true, redemptionType: "scandic",
savedCreditCardsEnabled: true, savedCreditCardsEnabled: true,
enterDetailsMembershipIdInputLocation: "form", enterDetailsMembershipIdInputLocation: "form",
variant: "scandic", variant: "scandic",

View File

@@ -8,9 +8,10 @@ import type { LangRoute } from "@scandic-hotels/common/constants/routes/langRout
import type { BookingFlowVariant } from "./bookingFlowVariants" import type { BookingFlowVariant } from "./bookingFlowVariants"
export type RedemptionType = "scandic" | "partner" | "disabled"
export type BookingFlowConfig = { export type BookingFlowConfig = {
bookingCodeEnabled: boolean bookingCodeEnabled: boolean
redemptionEnabled: boolean redemptionType: RedemptionType
savedCreditCardsEnabled: boolean savedCreditCardsEnabled: boolean
enterDetailsMembershipIdInputLocation: "form" | "join-card" enterDetailsMembershipIdInputLocation: "form" | "join-card"
variant: BookingFlowVariant variant: BookingFlowVariant

View File

@@ -79,7 +79,7 @@ export default function RewardNight() {
} }
}, [resetOnMultiroomError, ref]) }, [resetOnMultiroomError, ref])
if (!config.redemptionEnabled) return null if (config.redemptionType === "disabled") return null
return ( return (
<div ref={ref} onBlur={(e) => closeOnBlur(e.nativeEvent)}> <div ref={ref} onBlur={(e) => closeOnBlur(e.nativeEvent)}>

View File

@@ -41,7 +41,7 @@ export default function FormContent({
const { const {
formState: { errors, isDirty }, formState: { errors, isDirty },
} = useFormContext<BookingWidgetSchema>() } = useFormContext<BookingWidgetSchema>()
const { bookingCodeEnabled, redemptionEnabled } = useBookingFlowConfig() const { bookingCodeEnabled, redemptionType } = useBookingFlowConfig()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const focusWidget = searchParams.get(FOCUS_WIDGET) === "true" const focusWidget = searchParams.get(FOCUS_WIDGET) === "true"
useEffect(() => { useEffect(() => {
@@ -126,7 +126,7 @@ export default function FormContent({
)} )}
style={{ style={{
// TODO: Remove this when redemption is enabled for partner-sas // TODO: Remove this when redemption is enabled for partner-sas
display: redemptionEnabled ? undefined : "none", display: redemptionType !== "disabled" ? undefined : "none",
}} }}
> >
<Voucher /> <Voucher />

View File

@@ -28,7 +28,7 @@ import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { BookingStatusEnum } from "@scandic-hotels/trpc/enums/bookingStatus" import { BookingStatusEnum } from "@scandic-hotels/trpc/enums/bookingStatus"
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter" import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import { env } from "../../../../env/client" import { useBookingFlowConfig } from "../../../bookingFlowConfig/bookingFlowConfigContext"
import { useBookingFlowContext } from "../../../hooks/useBookingFlowContext" import { useBookingFlowContext } from "../../../hooks/useBookingFlowContext"
import { clearBookingWidgetState } from "../../../hooks/useBookingWidgetState" import { clearBookingWidgetState } from "../../../hooks/useBookingWidgetState"
import { useHandleBookingStatus } from "../../../hooks/useHandleBookingStatus" import { useHandleBookingStatus } from "../../../hooks/useHandleBookingStatus"
@@ -40,9 +40,10 @@ import { writeGlaToSessionStorage } from "./PaymentCallback/helpers"
import BookingAlert from "./BookingAlert" import BookingAlert from "./BookingAlert"
import { GuaranteeInfo } from "./GuaranteeInfo" import { GuaranteeInfo } from "./GuaranteeInfo"
import { import {
getPaymentData,
getPaymentMethod,
hasFlexibleRate, hasFlexibleRate,
hasPrepaidRate, hasPrepaidRate,
isPaymentMethodEnum,
mustGuaranteeBooking, mustGuaranteeBooking,
writePaymentInfoToSessionStorage, writePaymentInfoToSessionStorage,
} from "./helpers" } from "./helpers"
@@ -51,7 +52,6 @@ import { getPaymentHeadingConfig } from "./utils"
import styles from "./payment.module.css" import styles from "./payment.module.css"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { CreateBookingInput } from "@scandic-hotels/trpc/routers/booking/mutation/create/schema" import type { CreateBookingInput } from "@scandic-hotels/trpc/routers/booking/mutation/create/schema"
import type { CreditCard } from "@scandic-hotels/trpc/types/user" import type { CreditCard } from "@scandic-hotels/trpc/types/user"
@@ -77,6 +77,7 @@ export default function PaymentClient({
const searchParams = useSearchParams() const searchParams = useSearchParams()
const { getTopOffset } = useStickyPosition({}) const { getTopOffset } = useStickyPosition({})
const { user, isLoggedIn } = useBookingFlowContext() const { user, isLoggedIn } = useBookingFlowContext()
const { redemptionType } = useBookingFlowConfig()
const [refId, setRefId] = useState("") const [refId, setRefId] = useState("")
const [isPollingForBookingStatus, setIsPollingForBookingStatus] = const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
useState(false) useState(false)
@@ -273,6 +274,7 @@ export default function PaymentClient({
return firstIncompleteRoomIndex !== -1 return firstIncompleteRoomIndex !== -1
}, [runPreSubmitCallbacks, rooms, methods.formState.errors, getTopOffset]) }, [runPreSubmitCallbacks, rooms, methods.formState.errors, getTopOffset])
const isRedemptionBooking = booking.searchType === SEARCH_TYPE_REDEMPTION
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (data: PaymentFormData) => { async (data: PaymentFormData) => {
setIsSubmitting(true) setIsSubmitting(true)
@@ -283,19 +285,26 @@ export default function PaymentClient({
return return
} }
const paymentMethod = getPaymentMethod(data.paymentMethod, hasFlexRates)
const savedCreditCard = savedCreditCards?.find( const savedCreditCard = savedCreditCards?.find(
(card) => card.id === data.paymentMethod (card) => card.id === data.paymentMethod
) )
const guarantee = data.guarantee const guarantee = data.guarantee
const paymentMethod = getPaymentMethod({
const shouldUsePayment = paymentMethod: data.paymentMethod,
guarantee || bookingMustBeGuaranteed || !hasOnlyFlexRates hasFlexRates,
const payment = shouldUsePayment isRedemptionBooking,
? getPaymentData({ paymentMethod, savedCreditCard, lang }) redemptionType,
: undefined })
const payment = getPaymentData({
guarantee,
bookingMustBeGuaranteed,
hasOnlyFlexRates,
paymentMethod,
savedCreditCard,
isRedemptionBooking,
lang,
})
const paymentMethodType = savedCreditCard const paymentMethodType = savedCreditCard
? savedCreditCard.type ? savedCreditCard.type
@@ -315,7 +324,7 @@ export default function PaymentClient({
checkOutDate: toDate, checkOutDate: toDate,
hotelId, hotelId,
language: lang, language: lang,
payment, payment: payment ?? undefined,
rooms: rooms.map( rooms: rooms.map(
({ room }, idx): CreateBookingInput["rooms"][number] => { ({ room }, idx): CreateBookingInput["rooms"][number] => {
const isMainRoom = idx === 0 const isMainRoom = idx === 0
@@ -414,14 +423,16 @@ export default function PaymentClient({
[ [
setIsSubmitting, setIsSubmitting,
scrollToInvalidField, scrollToInvalidField,
hasFlexRates,
savedCreditCards, savedCreditCards,
hasFlexRates,
redemptionType,
bookingMustBeGuaranteed, bookingMustBeGuaranteed,
hasOnlyFlexRates, hasOnlyFlexRates,
lang, lang,
isRedemptionBooking,
hotelId,
fromDate, fromDate,
toDate, toDate,
hotelId,
rooms, rooms,
initiateBooking, initiateBooking,
isLoggedIn, isLoggedIn,
@@ -440,7 +451,6 @@ export default function PaymentClient({
const { preHeading, heading, subHeading, showLearnMore } = const { preHeading, heading, subHeading, showLearnMore } =
getPaymentHeadingConfig(intl, bookingMustBeGuaranteed, hasOnlyFlexRates) getPaymentHeadingConfig(intl, bookingMustBeGuaranteed, hasOnlyFlexRates)
const isRedemptionBooking = booking.searchType === SEARCH_TYPE_REDEMPTION
return ( return (
<section <section
className={cx(styles.paymentSection, { className={cx(styles.paymentSection, {
@@ -522,22 +532,6 @@ const scrollToElement = (el: HTMLElement, offset: number) => {
input?.focus({ preventScroll: true }) input?.focus({ preventScroll: true })
} }
const getPaymentMethod = (
paymentMethod: string | null | undefined,
hasFlexRates: boolean
): PaymentMethodEnum => {
if (hasFlexRates) {
return PaymentMethodEnum.card
}
return paymentMethod && isPaymentMethodEnum(paymentMethod)
? paymentMethod
: PaymentMethodEnum.card
}
function createPaymentCallbackUrl(lang: Lang) {
return `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback`
}
function useBookingStatusRedirect({ function useBookingStatusRedirect({
refId, refId,
enabled, enabled,
@@ -584,32 +578,6 @@ function useBookingStatusRedirect({
}, [bookingStatus.data, bookingStatus.isTimeout, router, intl, lang, onError]) }, [bookingStatus.data, bookingStatus.isTimeout, router, intl, lang, onError])
} }
function getPaymentData({
paymentMethod,
savedCreditCard,
lang,
}: {
paymentMethod: PaymentMethodEnum
savedCreditCard?: CreditCard
lang: Lang
}) {
const paymentRedirectUrl = createPaymentCallbackUrl(lang)
return {
paymentMethod: paymentMethod,
success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`,
card: savedCreditCard
? {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
: undefined,
}
}
function isNotNull<T>(value: T | null): value is T { function isNotNull<T>(value: T | null): value is T {
return value !== null return value !== null
} }

View File

@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { mustGuaranteeBooking } from "./helpers" import { Lang } from "@scandic-hotels/common/constants/language"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
import {
getPaymentData,
getPaymentMethod,
mustGuaranteeBooking,
} from "./helpers"
const buildRoom = ( const buildRoom = (
overrides: Partial<{ overrides: Partial<{
@@ -96,3 +103,179 @@ describe("mustGuaranteeBooking", () => {
).toBe(true) ).toBe(true)
}) })
}) })
describe("getPaymentData", () => {
it("returns correct URLs and method when guarantee is true", () => {
const result = getPaymentData({
paymentMethod: PaymentMethodEnum.swish,
lang: Lang.en,
guarantee: true,
bookingMustBeGuaranteed: false,
hasOnlyFlexRates: true,
isRedemptionBooking: false,
})
expect(result).toEqual({
paymentMethod: PaymentMethodEnum.swish,
success: `/en/hotelreservation/payment-callback/success`,
error: `/en/hotelreservation/payment-callback/error`,
cancel: `/en/hotelreservation/payment-callback/cancel`,
})
})
it("returns correct URLs and method when bookingMustBeGuaranteed", () => {
const result = getPaymentData({
paymentMethod: PaymentMethodEnum.swish,
lang: Lang.en,
guarantee: false,
bookingMustBeGuaranteed: true,
hasOnlyFlexRates: true,
isRedemptionBooking: false,
})
expect(result).toEqual({
paymentMethod: PaymentMethodEnum.swish,
success: `/en/hotelreservation/payment-callback/success`,
error: `/en/hotelreservation/payment-callback/error`,
cancel: `/en/hotelreservation/payment-callback/cancel`,
})
})
it("returns correct URLs and method when has only flex rates is false", () => {
const result = getPaymentData({
paymentMethod: PaymentMethodEnum.swish,
lang: Lang.en,
guarantee: false,
bookingMustBeGuaranteed: false,
hasOnlyFlexRates: false,
isRedemptionBooking: false,
})
expect(result).toEqual({
paymentMethod: PaymentMethodEnum.swish,
success: `/en/hotelreservation/payment-callback/success`,
error: `/en/hotelreservation/payment-callback/error`,
cancel: `/en/hotelreservation/payment-callback/cancel`,
})
})
it("returns null when payment isn't required", () => {
const result = getPaymentData({
paymentMethod: PaymentMethodEnum.swish,
lang: Lang.en,
guarantee: false,
bookingMustBeGuaranteed: false,
hasOnlyFlexRates: true,
isRedemptionBooking: false,
})
expect(result).toBeNull()
})
it("returns saved credit card when provided", () => {
const result = getPaymentData({
paymentMethod: PaymentMethodEnum.card,
lang: Lang.en,
guarantee: false,
bookingMustBeGuaranteed: false,
hasOnlyFlexRates: false,
isRedemptionBooking: false,
savedCreditCard: {
alias: "My Visa",
expirationDate: "12/25",
cardType: "visa",
id: "",
type: "",
truncatedNumber: "",
},
})
expect(result).toEqual({
paymentMethod: PaymentMethodEnum.card,
success: `/en/hotelreservation/payment-callback/success`,
error: `/en/hotelreservation/payment-callback/error`,
cancel: `/en/hotelreservation/payment-callback/cancel`,
card: {
alias: "My Visa",
expiryDate: "12/25",
cardType: "visa",
},
})
})
it("returns correct URLs and method when isRedemptionBooking is true and type is PartnerPoints", () => {
const result = getPaymentData({
paymentMethod: PaymentMethodEnum.PartnerPoints,
lang: Lang.en,
guarantee: false,
bookingMustBeGuaranteed: false,
hasOnlyFlexRates: true,
isRedemptionBooking: true,
})
expect(result).toEqual({
paymentMethod: PaymentMethodEnum.PartnerPoints,
success: `/en/hotelreservation/payment-callback/success`,
error: `/en/hotelreservation/payment-callback/error`,
cancel: `/en/hotelreservation/payment-callback/cancel`,
})
})
})
describe("getPaymentMethod", () => {
it("returns card when hasFlexRates is true", () => {
const hasFlexRates = true
const isRedemptionBooking = false
const redemptionType = "scandic"
const method = getPaymentMethod({
paymentMethod: PaymentMethodEnum.swish,
hasFlexRates,
isRedemptionBooking,
redemptionType,
})
expect(method).toBe(PaymentMethodEnum.card)
})
it("returns PartnerPoints when is redemption and redemptionType is partner", () => {
const hasFlexRates = false
const isRedemptionBooking = true
const redemptionType = "partner"
const method = getPaymentMethod({
paymentMethod: PaymentMethodEnum.swish,
hasFlexRates,
isRedemptionBooking,
redemptionType,
})
expect(method).toBe(PaymentMethodEnum.PartnerPoints)
})
it("returns paymentMethod when not redemption but redemptionType is partner", () => {
const hasFlexRates = false
const isRedemptionBooking = false
const redemptionType = "partner"
const method = getPaymentMethod({
paymentMethod: PaymentMethodEnum.swish,
hasFlexRates,
isRedemptionBooking,
redemptionType,
})
expect(method).toBe(PaymentMethodEnum.swish)
})
it("returns card when payment method is string", () => {
const hasFlexRates = false
const isRedemptionBooking = false
const redemptionType = "scandic"
const method = getPaymentMethod({
paymentMethod: "something-else",
hasFlexRates,
isRedemptionBooking,
redemptionType,
})
expect(method).toBe(PaymentMethodEnum.card)
})
})

View File

@@ -1,6 +1,12 @@
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod" import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
import { logger } from "@scandic-hotels/common/logger" import { logger } from "@scandic-hotels/common/logger"
import { env } from "../../../../env/client"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { CreditCard } from "@scandic-hotels/trpc/types/user"
import type { RedemptionType } from "../../../bookingFlowConfig/bookingFlowConfig"
import type { RoomState } from "../../../stores/enter-details/types" import type { RoomState } from "../../../stores/enter-details/types"
export function isPaymentMethodEnum(value: string): value is PaymentMethodEnum { export function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
@@ -123,3 +129,84 @@ export function mustGuaranteeBooking({
return room.mustBeGuaranteed return room.mustBeGuaranteed
}) })
} }
function createPaymentCallbackUrl(lang: Lang) {
return `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback`
}
export function getPaymentData({
guarantee,
bookingMustBeGuaranteed,
hasOnlyFlexRates,
paymentMethod,
isRedemptionBooking,
savedCreditCard,
lang,
}: {
guarantee: boolean
bookingMustBeGuaranteed: boolean
hasOnlyFlexRates: boolean
paymentMethod: PaymentMethodEnum
isRedemptionBooking: boolean
savedCreditCard?: CreditCard
lang: Lang
}) {
const paymentRedirectUrl = createPaymentCallbackUrl(lang)
const redirectUrls = {
success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`,
}
if (
isRedemptionBooking &&
paymentMethod === PaymentMethodEnum.PartnerPoints
) {
return {
paymentMethod: paymentMethod,
...redirectUrls,
}
}
const shouldUsePayment =
guarantee || bookingMustBeGuaranteed || !hasOnlyFlexRates
if (!shouldUsePayment) {
return null
}
return {
paymentMethod: paymentMethod,
...redirectUrls,
card: savedCreditCard
? {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
: undefined,
}
}
export const getPaymentMethod = ({
paymentMethod,
hasFlexRates,
isRedemptionBooking,
redemptionType,
}: {
paymentMethod: string | null | undefined
hasFlexRates: boolean
isRedemptionBooking: boolean
redemptionType: RedemptionType
}): PaymentMethodEnum => {
if (isRedemptionBooking && redemptionType === "partner") {
return PaymentMethodEnum.PartnerPoints
}
if (hasFlexRates) {
return PaymentMethodEnum.card
}
return paymentMethod && isPaymentMethodEnum(paymentMethod)
? paymentMethod
: PaymentMethodEnum.card
}

View File

@@ -48,7 +48,7 @@ export async function EnterDetailsPage({
// This should never happen unless a user tampers with the URL // This should never happen unless a user tampers with the URL
if ( if (
!config.redemptionEnabled && config.redemptionType === "disabled" &&
booking.searchType === SEARCH_TYPE_REDEMPTION booking.searchType === SEARCH_TYPE_REDEMPTION
) { ) {
throw new Error("Redemptions are disabled") throw new Error("Redemptions are disabled")

View File

@@ -19,6 +19,8 @@ import { serverClient } from "../trpc"
import type { Lang } from "@scandic-hotels/common/constants/language" import type { Lang } from "@scandic-hotels/common/constants/language"
import type { NextSearchParams } from "../types" import type { NextSearchParams } from "../types"
import { CreateBookingSchema } from "@scandic-hotels/trpc/routers/booking/mutation/create/schema"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
type PaymentCallbackPageProps = { type PaymentCallbackPageProps = {
lang: Lang lang: Lang
@@ -48,11 +50,28 @@ export async function PaymentCallbackPage({
} }
const returnUrl = details(lang) const returnUrl = details(lang)
const searchObject = new URLSearchParams()
let errorMessage = undefined
if (status === PaymentCallbackStatusEnum.Cancel) { if (status === PaymentCallbackStatusEnum.Cancel) {
const searchObject = new URLSearchParams()
searchObject.set("errorCode", BookingErrorCodeEnum.TransactionCancelled) searchObject.set("errorCode", BookingErrorCodeEnum.TransactionCancelled)
return (
<BookingFlowConfig config={config}>
<HandleErrorCallback
returnUrl={returnUrl.toString()}
searchObject={searchObject}
status={status}
/>
</BookingFlowConfig>
)
}
if (status === PaymentCallbackStatusEnum.Error) {
logger.error(
`[payment-callback] error status received for ${confirmationNumber}, status: ${status}`
)
const searchObject = new URLSearchParams()
searchObject.set("errorCode", BookingErrorCodeEnum.TransactionFailed)
const errorMessage = `Failed to get booking status for ${confirmationNumber}, status: ${status}`
return ( return (
<BookingFlowConfig config={config}> <BookingFlowConfig config={config}>
<HandleErrorCallback <HandleErrorCallback
@@ -81,14 +100,24 @@ export async function PaymentCallbackPage({
} }
const booking = await getBooking(confirmationNumber, lang, token) const booking = await getBooking(confirmationNumber, lang, token)
const refId = booking?.refId const refId = booking?.refId
if ( const caller = await serverClient()
status === PaymentCallbackStatusEnum.Success && const bookingStatus = refId
confirmationNumber && ? await caller.booking.status({
refId refId,
) { })
: null
if (status === PaymentCallbackStatusEnum.Success && refId) {
const shouldValidatePayment =
bookingStatus?.booking.paymentMethod === PaymentMethodEnum.PartnerPoints
if (shouldValidatePayment) {
// TODO We probably need better error handling for this mutation,
// but for now we've just implemented the happy path
await caller.booking.validatePartnerPayment({ confirmationNumber })
}
const expire = Math.floor(Date.now() / 1000) + 60 const expire = Math.floor(Date.now() / 1000) + 60
const sig = encrypt(expire.toString()) const sig = encrypt(expire.toString())
const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(refId)}` const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(refId)}`
@@ -108,49 +137,67 @@ export async function PaymentCallbackPage({
) )
} }
if (refId) { return (
try { <HandleBookingStatusError
const caller = await serverClient() status={status}
const bookingStatus = await caller.booking.status({ booking={bookingStatus?.booking ?? null}
refId, returnUrl={returnUrl.toString()}
}) confirmationNumber={confirmationNumber}
config={config}
/>
)
}
const { booking } = bookingStatus function HandleBookingStatusError({
booking,
// TODO: how to handle errors for multiple rooms? confirmationNumber,
const error = booking.errors.find((e) => e.errorCode) returnUrl,
config,
errorMessage = status,
error?.description ?? }: {
`No error message found for booking ${confirmationNumber}, status: ${status}` booking: CreateBookingSchema | null
confirmationNumber?: string
searchObject.set( returnUrl: string
"errorCode", config: BookingFlowConfig
error status: PaymentCallbackStatusEnum
? error.errorCode.toString() }) {
: BookingErrorCodeEnum.TransactionFailed if (!booking) {
)
} catch {
logger.error(
`[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}`
)
searchObject.set("errorCode", BookingErrorCodeEnum.TransactionFailed)
errorMessage = `Failed to get booking status for ${confirmationNumber}, status: ${status}`
}
}
if (status === PaymentCallbackStatusEnum.Error) {
logger.error( logger.error(
`[payment-callback] error status received for ${confirmationNumber}, status: ${status}` `[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}`
) )
const searchObject = new URLSearchParams()
searchObject.set("errorCode", BookingErrorCodeEnum.TransactionFailed) searchObject.set("errorCode", BookingErrorCodeEnum.TransactionFailed)
errorMessage = `Failed to get booking status for ${confirmationNumber}, status: ${status}` const errorMessage = `Failed to get booking status for ${confirmationNumber}, status: ${status}`
return (
<BookingFlowConfig config={config}>
<HandleErrorCallback
returnUrl={returnUrl}
searchObject={searchObject}
status={status}
errorMessage={errorMessage}
/>
</BookingFlowConfig>
)
} }
// TODO: how to handle errors for multiple rooms?
const error = booking.errors.find((e) => e.errorCode)
const errorMessage =
error?.description ??
`No error message found for booking ${confirmationNumber}, status: ${status}`
const searchObject = new URLSearchParams()
searchObject.set(
"errorCode",
error ? error.errorCode.toString() : BookingErrorCodeEnum.TransactionFailed
)
return ( return (
<BookingFlowConfig config={config}> <BookingFlowConfig config={config}>
<HandleErrorCallback <HandleErrorCallback
returnUrl={returnUrl.toString()} returnUrl={returnUrl}
searchObject={searchObject} searchObject={searchObject}
status={status} status={status}
errorMessage={errorMessage} errorMessage={errorMessage}

View File

@@ -20,6 +20,7 @@ export enum PaymentMethodEnum {
maestro = "maestro", maestro = "maestro",
chinaUnionPay = "chinaUnionPay", chinaUnionPay = "chinaUnionPay",
discover = "discover", discover = "discover",
PartnerPoints = "PartnerPoints",
} }
export const PAYMENT_METHOD_TITLES: Record< export const PAYMENT_METHOD_TITLES: Record<
@@ -45,4 +46,5 @@ export const PAYMENT_METHOD_TITLES: Record<
maestro: "Maestro", maestro: "Maestro",
chinaUnionPay: "China UnionPay", chinaUnionPay: "China UnionPay",
discover: "Discover", discover: "Discover",
PartnerPoints: "N/A",
} }

View File

@@ -40,6 +40,7 @@ const paymentMethods: Partial<
maestro: (props) => <MaestroIcon {...props} />, maestro: (props) => <MaestroIcon {...props} />,
chinaUnionPay: (props) => <ChinaUnionPayIcon {...props} />, chinaUnionPay: (props) => <ChinaUnionPayIcon {...props} />,
discover: (props) => <DiscoverIcon {...props} />, discover: (props) => <DiscoverIcon {...props} />,
PartnerPoints: () => null,
} }
type PaymentMethodIconProps = { type PaymentMethodIconProps = {

View File

@@ -82,6 +82,9 @@ export namespace endpoints {
export function confirmNotification(confirmationNumber: string) { export function confirmNotification(confirmationNumber: string) {
return `${bookings}/${confirmationNumber}/confirmNotification` return `${bookings}/${confirmationNumber}/confirmNotification`
} }
export function validatePartnerPayment(confirmationNumber: string) {
return `${bookings}/${confirmationNumber}/validate`
}
export const enum Stays { export const enum Stays {
future = `${base.path.booking}/${version}/${base.enitity.Stays}/future`, future = `${base.path.booking}/${version}/${base.enitity.Stays}/future`,

View File

@@ -1,5 +1,6 @@
import "server-only" import "server-only"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
import { createCounter } from "@scandic-hotels/common/telemetry" import { createCounter } from "@scandic-hotels/common/telemetry"
import * as api from "../../../../api" import * as api from "../../../../api"
@@ -38,6 +39,23 @@ export const create = safeProtectedServiceProcedure
Authorization: `Bearer ${ctx.token ?? ctx.serviceToken}`, Authorization: `Bearer ${ctx.token ?? ctx.serviceToken}`,
} }
const includePartnerSpecific =
inputWithoutLang.payment?.paymentMethod ===
PaymentMethodEnum.PartnerPoints
if (includePartnerSpecific) {
const session = await ctx.auth()
const token = session?.token.access_token
if (!token) {
throw new Error(
"Cannot create booking with partner points without partner token"
)
}
inputWithoutLang.partnerSpecific = {
eurobonusAccessToken: session?.token.access_token,
}
}
const apiResponse = await api.post( const apiResponse = await api.post(
api.endpoints.v1.Booking.bookings, api.endpoints.v1.Booking.bookings,
{ {
@@ -60,7 +78,6 @@ export const create = safeProtectedServiceProcedure
} }
const apiJson = await apiResponse.json() const apiJson = await apiResponse.json()
const verifiedData = createBookingSchema.safeParse(apiJson) const verifiedData = createBookingSchema.safeParse(apiJson)
if (!verifiedData.success) { if (!verifiedData.success) {
metricsCreateBooking.validationError(verifiedData.error) metricsCreateBooking.validationError(verifiedData.error)

View File

@@ -105,6 +105,11 @@ export const createBookingInput = z.object({
rooms: roomsSchema, rooms: roomsSchema,
payment: paymentSchema.optional(), payment: paymentSchema.optional(),
language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]), language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]),
partnerSpecific: z
.object({
eurobonusAccessToken: z.string(),
})
.optional(),
}) })
export const createBookingSchema = z export const createBookingSchema = z
@@ -114,6 +119,7 @@ export const createBookingSchema = z
reservationStatus: z.string(), reservationStatus: z.string(),
guest: guestSchema.optional(), guest: guestSchema.optional(),
paymentUrl: z.string().nullable().optional(), paymentUrl: z.string().nullable().optional(),
paymentMethod: z.string().nullable().optional(),
rooms: z rooms: z
.array( .array(
z.object({ z.object({
@@ -161,6 +167,7 @@ export const createBookingSchema = z
type: d.data.type, type: d.data.type,
reservationStatus: d.data.attributes.reservationStatus, reservationStatus: d.data.attributes.reservationStatus,
paymentUrl: d.data.attributes.paymentUrl, paymentUrl: d.data.attributes.paymentUrl,
paymentMethod: d.data.attributes.paymentMethod,
rooms: d.data.attributes.rooms.map((room) => { rooms: d.data.attributes.rooms.map((room) => {
const lastName = d.data.attributes.guest?.lastName ?? "" const lastName = d.data.attributes.guest?.lastName ?? ""
return { return {
@@ -171,3 +178,4 @@ export const createBookingSchema = z
errors: d.data.attributes.errors, errors: d.data.attributes.errors,
guest: d.data.attributes.guest, guest: d.data.attributes.guest,
})) }))
export type CreateBookingSchema = z.infer<typeof createBookingSchema>

View File

@@ -17,12 +17,14 @@ import { bookingConfirmationSchema } from "../output"
import { cancelBooking } from "../utils" import { cancelBooking } from "../utils"
import { createBookingSchema } from "./create/schema" import { createBookingSchema } from "./create/schema"
import { create } from "./create" import { create } from "./create"
import { validatePartnerPayment } from "./validatePartnerPayment"
const refIdPlugin = createRefIdPlugin() const refIdPlugin = createRefIdPlugin()
const bookingLogger = createLogger("trpc.booking") const bookingLogger = createLogger("trpc.booking")
export const bookingMutationRouter = router({ export const bookingMutationRouter = router({
create, create,
validatePartnerPayment,
priceChange: safeProtectedServiceProcedure priceChange: safeProtectedServiceProcedure
.concat(refIdPlugin.toConfirmationNumber) .concat(refIdPlugin.toConfirmationNumber)
.use(async ({ ctx, next }) => { .use(async ({ ctx, next }) => {

View File

@@ -0,0 +1,60 @@
import "server-only"
import z from "zod"
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as api from "../../../api"
import { serverErrorByStatus } from "../../../errors"
import { safeProtectedServiceProcedure } from "../../../procedures"
import { toApiLang } from "../../../utils"
const validatePartnerPaymentInput = z.object({
confirmationNumber: z.string(),
})
export const validatePartnerPayment = safeProtectedServiceProcedure
.input(validatePartnerPaymentInput)
.use(async ({ ctx, next }) => {
const token = await ctx.getScandicUserToken()
return next({
ctx: {
token,
},
})
})
.mutation(async function ({ ctx, input }) {
const { confirmationNumber } = input
const getValidateBooking = createCounter("booking.validate")
const metricsValidateBooking = getValidateBooking.init({
confirmationNumber,
})
metricsValidateBooking.start()
const apiResponse = await api.put(
api.endpoints.v1.Booking.validatePartnerPayment(confirmationNumber),
{
headers: {
Authorization: `Bearer ${ctx.token ?? ctx.serviceToken}`,
},
},
{ language: toApiLang(ctx.lang) }
)
if (!apiResponse.ok) {
await metricsValidateBooking.httpError(apiResponse)
// If the booking is not found, return null.
if (apiResponse.status === 404) {
return null
}
throw serverErrorByStatus(apiResponse.status, apiResponse)
}
metricsValidateBooking.success()
return null
})