diff --git a/examples/magento-graphcms/package.json b/examples/magento-graphcms/package.json index 69cfe46c88f..0d1dab7c024 100644 --- a/examples/magento-graphcms/package.json +++ b/examples/magento-graphcms/package.json @@ -52,6 +52,7 @@ "@graphcommerce/magento-customer": "7.1.0-canary.61", "@graphcommerce/magento-graphql": "7.1.0-canary.61", "@graphcommerce/magento-newsletter": "7.1.0-canary.61", + "@graphcommerce/magento-payment-adyen": "7.1.0-canary.61", "@graphcommerce/magento-payment-included": "7.1.0-canary.61", "@graphcommerce/magento-product": "7.1.0-canary.61", "@graphcommerce/magento-product-bundle": "7.1.0-canary.61", diff --git a/packages/magento-payment-adyen/README.md b/packages/magento-payment-adyen/README.md index 3319eec4362..633b790b9e2 100644 --- a/packages/magento-payment-adyen/README.md +++ b/packages/magento-payment-adyen/README.md @@ -6,9 +6,6 @@ We currently have 'Alternative Payment Methods' implemented, this means that it supports all off-site payment methods that Adyen supports. This includes CC, iDeal, Bancontact, Sofort, etc. -We do not support on-site credit cards yet. Let us know if you want to have -this. - ## Requirements - Magento Adyen module version 8.5.0 or later @@ -17,16 +14,12 @@ this. 1. Find current version of your `@graphcommerce/magento-cart-payment-method` in your package.json. -2. `yarn add @graphcommerce/magento-payment-adyen@1.2.3` (replace 1.2.3 with the - version of the step above) - +2. `yarn add @marcheygroup/graphcommerce-magento-payment-adyen@^1.2.3` (replace + 1.2.3 with the version of the step above) 3. Configure the Adyen module in Magento Admin like you would normally do. -4. Stores -> Configuration -> Sales -> Payment Methods -> Adyen Payment methods - -> Headless integration -> Payment Origin URL: `https://www.yourdomain.com` -5. Stores -> Configuration -> Sales -> Payment Methods -> Adyen Payment methods - -> Headless integration -> Payment Return URL: - `https://www.yourdomain.com/checkout/payment?locked=1&adyen=1` (make sure the - URL's match for your storeview) +4. Configure the Payment Origin URL Stores -> Configuration -> Sales -> Payment + Methods -> Adyen Payment methods -> Headless integration -> Payment Origin + URL: `https://www.yourdomain.com` This package uses GraphCommerce plugin systems, so there is no code modification required. @@ -36,6 +29,3 @@ required. - We don't need to configure the Payment URL's anymore since the 8.3.3 release https://github.com/Adyen/adyen-magento2/releases/tag/8.3.3, but that isn't integrated in the frontend yet. - -- This package is currently untested inside the GraphCommerce repo, which it - should, but is used in production for multiple shops. diff --git a/packages/magento-payment-adyen/components/AdyenPaymentActionCard/AdyenPaymentActionCard.tsx b/packages/magento-payment-adyen/components/AdyenPaymentActionCard/AdyenPaymentActionCard.tsx index 7efda0cdeb0..23413e895de 100644 --- a/packages/magento-payment-adyen/components/AdyenPaymentActionCard/AdyenPaymentActionCard.tsx +++ b/packages/magento-payment-adyen/components/AdyenPaymentActionCard/AdyenPaymentActionCard.tsx @@ -2,30 +2,44 @@ import { Image } from '@graphcommerce/image' import { PaymentMethodActionCardProps } from '@graphcommerce/magento-cart-payment-method' import { ActionCard, useIconSvgSize } from '@graphcommerce/next-ui' import { Trans } from '@lingui/react' -import { useAdyenPaymentMethod } from '../../hooks/useAdyenPaymentMethod' +import applepay from './applepay.svg' +import googlepay from './googlepay.svg' +import paypal from './paypal.svg' +import scheme from './scheme.svg' export function AdyenPaymentActionCard(props: PaymentMethodActionCardProps) { const { child } = props const iconSize = useIconSvgSize('large') - const icon = useAdyenPaymentMethod(child)?.icon - + const icons = { + scheme: { + image: scheme, + }, + adyen_cc: { + image: scheme, + }, + applepay: { + image: applepay, + }, + googlepay: { + image: googlepay, + }, + paypal: { + image: paypal, + }, + } return ( } image={ - !!icon?.url && - !!icon?.width && - !!icon?.height && ( + !!icons[child]?.image && ( ) } diff --git a/packages/magento-payment-adyen/components/AdyenPaymentActionCard/applepay.svg b/packages/magento-payment-adyen/components/AdyenPaymentActionCard/applepay.svg new file mode 100644 index 00000000000..0c6ecafef27 --- /dev/null +++ b/packages/magento-payment-adyen/components/AdyenPaymentActionCard/applepay.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/magento-payment-adyen/components/AdyenPaymentActionCard/googlepay.svg b/packages/magento-payment-adyen/components/AdyenPaymentActionCard/googlepay.svg new file mode 100644 index 00000000000..a4212689d77 --- /dev/null +++ b/packages/magento-payment-adyen/components/AdyenPaymentActionCard/googlepay.svg @@ -0,0 +1,21 @@ + + + + Layer 1 + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/magento-payment-adyen/components/AdyenPaymentActionCard/paypal.svg b/packages/magento-payment-adyen/components/AdyenPaymentActionCard/paypal.svg new file mode 100644 index 00000000000..e644f23076a --- /dev/null +++ b/packages/magento-payment-adyen/components/AdyenPaymentActionCard/paypal.svg @@ -0,0 +1 @@ +paypal-seeklogo.com \ No newline at end of file diff --git a/packages/magento-payment-adyen/components/AdyenPaymentActionCard/scheme.svg b/packages/magento-payment-adyen/components/AdyenPaymentActionCard/scheme.svg new file mode 100644 index 00000000000..e5553eee2f8 --- /dev/null +++ b/packages/magento-payment-adyen/components/AdyenPaymentActionCard/scheme.svg @@ -0,0 +1,26 @@ + + + Layer 1 + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/magento-payment-adyen/graphql/AdyenStoreConfig.graphql b/packages/magento-payment-adyen/graphql/AdyenStoreConfig.graphql new file mode 100644 index 00000000000..975893af2a9 --- /dev/null +++ b/packages/magento-payment-adyen/graphql/AdyenStoreConfig.graphql @@ -0,0 +1,11 @@ +fragment AdyenStoreConfig on StoreConfig @inject(into: ["StoreConfigFragment"]) { + adyen_demo_mode + adyen_title_renderer + adyen_client_key_live + adyen_client_key_test + adyen_has_holder_name + adyen_return_path_error + adyen_oneclick_card_mode + adyen_holder_name_required + adyen_checkout_frontend_region +} diff --git a/packages/magento-payment-adyen/hooks/adyenHppExpandMethods.ts b/packages/magento-payment-adyen/hooks/adyenHppExpandMethods.ts deleted file mode 100644 index 671d093b533..00000000000 --- a/packages/magento-payment-adyen/hooks/adyenHppExpandMethods.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ExpandPaymentMethods } from '@graphcommerce/magento-cart-payment-method' -import { UseAdyenPaymentMethodsDocument } from './UseAdyenPaymentMethods.gql' - -export const nonNullable = (value: T): value is NonNullable => - value !== null && value !== undefined - -export const adyenHppExpandMethods: ExpandPaymentMethods = async (available, context) => { - if (!context.id) return [] - - const result = await context.client.query({ - query: UseAdyenPaymentMethodsDocument, - variables: { cartId: context.id }, - }) - - const methods = result.data.adyenPaymentMethods?.paymentMethodsResponse?.paymentMethods ?? [] - - return methods - .map((method) => { - if (!method?.name || !method.type) return null - - return { title: method.name, code: available.code, child: method.type } - }) - .filter(nonNullable) -} diff --git a/packages/magento-payment-adyen/hooks/useAdyenCheckoutConfig.ts b/packages/magento-payment-adyen/hooks/useAdyenCheckoutConfig.ts new file mode 100644 index 00000000000..1aa969461d3 --- /dev/null +++ b/packages/magento-payment-adyen/hooks/useAdyenCheckoutConfig.ts @@ -0,0 +1,72 @@ +import { CoreOptions } from '@adyen/adyen-web/dist/types/core/types' +import { ApolloError, QueryResult, useQuery } from '@graphcommerce/graphql' +import { useCartQuery } from '@graphcommerce/magento-cart' +import { StoreConfigDocument } from '@graphcommerce/magento-store' +import { filterNonNullableKeys, nonNullable, useMemoObject } from '@graphcommerce/next-ui' +import { useRouter } from 'next/router' +import { + UseAdyenPaymentMethodsDocument, + UseAdyenPaymentMethodsQuery, + UseAdyenPaymentMethodsQueryVariables, +} from './UseAdyenPaymentMethods.gql' + +export type CoreOptionsPartial = Pick< + CoreOptions, + 'environment' | 'clientKey' | 'locale' | 'paymentMethodsResponse' +> + +type UseAdyenCheckoutConfigResult = QueryResult< + UseAdyenPaymentMethodsQuery, + UseAdyenPaymentMethodsQueryVariables +> & { + config?: CoreOptionsPartial +} + +export function useAdyenCheckoutConfig(): UseAdyenCheckoutConfigResult { + const storeConfig = useQuery(StoreConfigDocument).data?.storeConfig ?? {} + const { locale } = useRouter() + + let { adyen_demo_mode, adyen_client_key_live, adyen_client_key_test } = storeConfig + if (!adyen_demo_mode) adyen_demo_mode = true + const clientKey = adyen_demo_mode ? adyen_client_key_test : adyen_client_key_live + + const paymentMethodsQuery = useCartQuery(UseAdyenPaymentMethodsDocument, { + fetchPolicy: 'network-only', + }) + + const response = paymentMethodsQuery.data?.adyenPaymentMethods?.paymentMethodsResponse + const paymentMethodsResponse = { + ...response, + paymentMethods: filterNonNullableKeys( + response?.paymentMethods, + // ['brand', 'brands', 'type', 'name', 'details', 'configuration', 'issuers'], + ).map((pm) => ({ ...pm, brands: (pm?.brands ?? []).filter(nonNullable) })), + } satisfies CoreOptions['paymentMethodsResponse'] + + const config = useMemoObject({ + environment: adyen_demo_mode ? 'test' : 'prod', + clientKey, + locale: locale?.split('-', 2).join('-'), + paymentMethodsResponse, + }) + + if (paymentMethodsQuery.loading || paymentMethodsQuery.error) { + return paymentMethodsQuery + } + + if (!config.paymentMethodsResponse) { + return { + ...paymentMethodsQuery, + error: new ApolloError({ errorMessage: 'No Adyen payment methods response found' }), + } + } + + if (!config.clientKey) { + return { + ...paymentMethodsQuery, + error: new ApolloError({ errorMessage: 'No Adyen client key found in store config' }), + } + } + + return { ...paymentMethodsQuery, config: { ...config, clientKey: config.clientKey } } +} diff --git a/packages/magento-payment-adyen/hooks/useAdyenHandlePaymentResponse.ts b/packages/magento-payment-adyen/hooks/useAdyenHandlePaymentResponse.ts index cf5bc2c9c73..a42aee7c745 100644 --- a/packages/magento-payment-adyen/hooks/useAdyenHandlePaymentResponse.ts +++ b/packages/magento-payment-adyen/hooks/useAdyenHandlePaymentResponse.ts @@ -20,7 +20,7 @@ export enum ResultCodeEnum { Success = 'Success', } -function isResultCodeEnum(value: string): value is ResultCodeEnum { +export function isResultCodeEnum(value: string): value is ResultCodeEnum { return Object.values(ResultCodeEnum).includes(value as ResultCodeEnum) } diff --git a/packages/magento-payment-adyen/hooks/useAdyenPaymentMethod.ts b/packages/magento-payment-adyen/hooks/useAdyenPaymentMethod.ts index 8aabb6527d8..6f4286abeb5 100644 --- a/packages/magento-payment-adyen/hooks/useAdyenPaymentMethod.ts +++ b/packages/magento-payment-adyen/hooks/useAdyenPaymentMethod.ts @@ -20,11 +20,12 @@ export function useAdyenPaymentMethod(brandCode: string) { return { ...methodConf, ...config, + paymentMethodsResponse: methods.data?.adyenPaymentMethods?.paymentMethodsResponse, } }, [ brandCode, methods.data?.adyenPaymentMethods?.paymentMethodsExtraDetails, - methods.data?.adyenPaymentMethods?.paymentMethodsResponse?.paymentMethods, + methods.data?.adyenPaymentMethods?.paymentMethodsResponse, ]) return result diff --git a/packages/magento-payment-adyen/index.ts b/packages/magento-payment-adyen/index.ts index d9954808c6a..642501fa28d 100644 --- a/packages/magento-payment-adyen/index.ts +++ b/packages/magento-payment-adyen/index.ts @@ -2,7 +2,7 @@ import { PaymentModule } from '@graphcommerce/magento-cart-payment-method' import { AdyenPaymentActionCard } from './components/AdyenPaymentActionCard/AdyenPaymentActionCard' import { AdyenPaymentHandler } from './components/AdyenPaymentHandler/AdyenPaymentHandler' import { HppOptions } from './components/AdyenPaymentOptionsAndPlaceOrder/AdyenPaymentOptionsAndPlaceOrder' -import { adyenHppExpandMethods } from './hooks/adyenHppExpandMethods' +import { adyenHppExpandMethods } from './methods/adyen_hpp/adyenHppExpandMethods' export const adyen_hpp: PaymentModule = { PaymentOptions: HppOptions, diff --git a/packages/magento-payment-adyen/lib/common.ts b/packages/magento-payment-adyen/lib/common.ts new file mode 100644 index 00000000000..c14e59d3bff --- /dev/null +++ b/packages/magento-payment-adyen/lib/common.ts @@ -0,0 +1,5 @@ +export function refresh() { + if (typeof window !== undefined) { + window.location.reload(); + } +} \ No newline at end of file diff --git a/packages/magento-payment-adyen/methods/adyen_cc/AdyenCcPaymentOptionsAndPlaceOrder.graphql b/packages/magento-payment-adyen/methods/adyen_cc/AdyenCcPaymentOptionsAndPlaceOrder.graphql new file mode 100644 index 00000000000..7826ede7502 --- /dev/null +++ b/packages/magento-payment-adyen/methods/adyen_cc/AdyenCcPaymentOptionsAndPlaceOrder.graphql @@ -0,0 +1,27 @@ +mutation AdyenCcPaymentOptionsAndPlaceOrder( + $cartId: String! + $stateData: String! + $returnUrl: String! +) { + setPaymentMethodOnCart( + input: { + cart_id: $cartId + payment_method: { + code: "adyen_cc" + adyen_additional_data_cc: { stateData: $stateData, returnUrl: $returnUrl } + } + } + ) { + cart { + ...PaymentMethodUpdated + } + } + placeOrder(input: { cart_id: $cartId }) { + order { + order_number + adyen_payment_status { + ...AdyenPaymentResponse + } + } + } +} diff --git a/packages/magento-payment-adyen/methods/adyen_cc/PaymentButton.tsx b/packages/magento-payment-adyen/methods/adyen_cc/PaymentButton.tsx new file mode 100644 index 00000000000..668d2d0e960 --- /dev/null +++ b/packages/magento-payment-adyen/methods/adyen_cc/PaymentButton.tsx @@ -0,0 +1,20 @@ +import { PaymentButtonProps } from '@graphcommerce/magento-cart-payment-method/Api/PaymentMethod' +import { LinkOrButton } from '@graphcommerce/next-ui' + +export function PaymentButton(props: PaymentButtonProps) { + const { buttonProps, title } = props + const isPlaceOrder = buttonProps?.id === 'place-order' + const isValid = true + + return ( + + {isPlaceOrder && props?.title && ( + <> + {buttonProps.children} ({title}) + + )} + + {!isPlaceOrder && <>Pay} + + ) +} diff --git a/packages/magento-payment-adyen/methods/adyen_cc/PaymentMethodOptions.tsx b/packages/magento-payment-adyen/methods/adyen_cc/PaymentMethodOptions.tsx new file mode 100644 index 00000000000..8b89e047ad4 --- /dev/null +++ b/packages/magento-payment-adyen/methods/adyen_cc/PaymentMethodOptions.tsx @@ -0,0 +1,217 @@ +import AdyenCheckout from '@adyen/adyen-web' +import { CardElement } from '@adyen/adyen-web/dist/types/components/Card/Card' +import Core from '@adyen/adyen-web/dist/types/core/core' +import { CoreOptions } from '@adyen/adyen-web/dist/types/core/types' +import { PaymentAction } from '@adyen/adyen-web/dist/types/types' +import { ApolloErrorSnackbar, useFormCompose } from '@graphcommerce/ecommerce-ui' +import { useLazyQuery, useMutation } from '@graphcommerce/graphql' +import { useFormGqlMutationCart, useCurrentCartId, useCartQuery } from '@graphcommerce/magento-cart' +import { BillingPageDocument } from '@graphcommerce/magento-cart-checkout' +import { + PaymentOptionsProps, + usePaymentMethodContext, +} from '@graphcommerce/magento-cart-payment-method' +import { ErrorSnackbar } from '@graphcommerce/next-ui' +import { composedFormContext } from '@graphcommerce/react-hook-form/src/ComposedForm/context' +import { Trans } from '@lingui/react' +import { Box } from '@mui/material' +import { useContext, useEffect, useRef, useState } from 'react' +import { AdyenPaymentDetailsDocument } from '../../components/AdyenPaymentHandler/AdyenPaymentDetails.gql' +import { AdyenPaymentStatusDocument } from '../../components/AdyenPaymentHandler/AdyenPaymentStatus.gql' +import { useAdyenCheckoutConfig } from '../../hooks/useAdyenCheckoutConfig' +import { ResultCodeEnum, isResultCodeEnum } from '../../hooks/useAdyenHandlePaymentResponse' +import { + AdyenCcPaymentOptionsAndPlaceOrderMutation, + AdyenCcPaymentOptionsAndPlaceOrderMutationVariables, + AdyenCcPaymentOptionsAndPlaceOrderDocument, +} from './AdyenCcPaymentOptionsAndPlaceOrder.gql' +import '@adyen/adyen-web/dist/adyen.css' + +const getResultCode = (result): ResultCodeEnum => + result?.data?.placeOrder?.order.adyen_payment_status?.resultCode && + isResultCodeEnum(result?.data?.placeOrder?.order.adyen_payment_status?.resultCode as string) + ? result?.data?.placeOrder?.order.adyen_payment_status?.resultCode + : ResultCodeEnum.Error + +type UseAdyenCheckoutOptions = Omit< + CoreOptions, + 'environment' | 'clientKey' | 'locale' | 'paymentMethodsResponse' +> + +function useAdyenCheckout(options?: UseAdyenCheckoutOptions) { + const [checkout, setCheckout] = useState(null) + const adyenCheckoutConfig = useAdyenCheckoutConfig() + + const optionsRef = useRef(options) + optionsRef.current = options + + useEffect(() => { + if (checkout || !adyenCheckoutConfig.config) return + ;(async () => { + setCheckout(await AdyenCheckout({ ...adyenCheckoutConfig.config, ...optionsRef.current })) + })().catch(console.error) + }, [checkout, adyenCheckoutConfig.config]) + + return checkout +} + +export function PaymentMethodOptions(props: PaymentOptionsProps) { + const { step, code, child: brandCode, Container } = props + const [showError, setShowError] = useState(false) + const action = useRef(undefined) + const orderNumber = useRef('') + const { currentCartId } = useCurrentCartId() + const [getDetails] = useMutation(AdyenPaymentDetailsDocument) + const [getStatus] = useLazyQuery(AdyenPaymentStatusDocument, { fetchPolicy: 'network-only' }) + const { selectedMethod, onSuccess } = usePaymentMethodContext() + const paymentContainer = useRef(null) + const component = useRef(null) + const [, dispatch] = useContext(composedFormContext) + const billingPage = useCartQuery(BillingPageDocument, { fetchPolicy: 'cache-and-network' }).data + ?.cart?.billing_address + + const checkout = useAdyenCheckout({ + onSubmit: (state) => { + const stateDataWithBillingAddress = { + ...state.data, + billingAddress: { + street: billingPage?.street?.[0], + postalCode: billingPage?.postcode, + city: billingPage?.city, + houseNumberOrName: billingPage?.street?.[1], + country: billingPage?.country?.code, + }, + } + // eslint-disable-next-line @typescript-eslint/no-use-before-define + if (state.isValid) setValue('stateData', JSON.stringify(stateDataWithBillingAddress)) + }, + onAdditionalDetails: async (state) => { + console.info(`${brandCode}: onAdditionalDetails`) + console.info('onAdditionalDetails', state) + + const payload = JSON.stringify({ orderId: orderNumber, details: state.data.details }) + + // Attempt 1; We first try and handle the payment for the order. + const details = await getDetails({ + errorPolicy: 'all', + variables: { cartId: currentCartId, payload }, + }) + + let paymentStatus = details.data?.adyenPaymentDetails + + // Attempt 2; The adyenPaymentDetails mutation failed, because it was already called previously or no payment had been made. + if (details.errors) { + const status = await getStatus({ + errorPolicy: 'all', + variables: { cartId: currentCartId, orderNumber: orderNumber.current }, + }) + paymentStatus = status.data?.adyenPaymentStatus + console.error(`payment failed: ${paymentStatus?.resultCode}`) + + // Restart component and show error message + component.current?.remount() + setShowError(true) + dispatch({ type: 'SUBMITTED', isSubmitSuccessful: false }) + } + + if (paymentStatus?.resultCode === ResultCodeEnum.Authorised) { + console.info(`${brandCode} payment success`) + await onSuccess(orderNumber.current) + } + }, + onError: (e) => { + console.error(e) + }, + }) + + useEffect(() => { + if (component.current || !checkout || !paymentContainer.current) return + component.current = checkout + .create('card', { + hasHolderName: true, + holderNameRequired: true, + billingAddressRequired: false, + }) + .mount(paymentContainer.current) + }, [checkout]) + + // Set Adyen client data on payment and place order + const form = useFormGqlMutationCart< + AdyenCcPaymentOptionsAndPlaceOrderMutation, + AdyenCcPaymentOptionsAndPlaceOrderMutationVariables & { issuer?: string } + >(AdyenCcPaymentOptionsAndPlaceOrderDocument, { + onBeforeSubmit: (vars) => { + if (!component.current) throw Error('Adyen component not mounted yet') + component.current.submit() + + // ?locked=1&adyen=1 + const currentUrl = new URL(window.location.href.replace(window.location.hash, '')) + currentUrl.searchParams.set('cart_id', vars.cartId) + currentUrl.searchParams.set('locked', '1') + currentUrl.searchParams.set('adyen', '1') + const returnUrl = currentUrl.toString() + + // eslint-disable-next-line @typescript-eslint/no-use-before-define + return { ...vars, stateData: getValues('stateData'), returnUrl } + }, + onComplete: async (result) => { + const merchantReference = result.data?.placeOrder?.order.order_number + if (merchantReference !== undefined && merchantReference !== null) { + orderNumber.current = merchantReference + } + + const isAction = result?.data?.placeOrder?.order.adyen_payment_status?.action + if (isAction !== undefined && isAction !== null) { + action.current = isAction + } + + const resultCode = getResultCode(result) + + // Case 1: Non-3DS/Place Order failure -> Restart component and show error message + if (result.errors || !merchantReference || !selectedMethod?.code) { + component.current?.remount() + setShowError(true) + dispatch({ type: 'SUBMITTED', isSubmitSuccessful: false }) + return + } + + // Case 2: Non-3DS Authorised successfully + if (resultCode === ResultCodeEnum.Authorised) { + console.info(`${brandCode} payment success`) + await onSuccess(merchantReference) + } + + // Case 3: 3DS challenge action -> start 3DS flow + if ( + (resultCode === ResultCodeEnum.IdentifyShopper || + resultCode === ResultCodeEnum.ChallengeShopper) && + action.current + ) { + component.current?.handleAction({ + ...JSON.parse(action.current), + // url: 'https://test.adyen.com/hpp/3d/validate.shtml', // <-- Remove after development + } as PaymentAction) + } + }, + }) + + const { handleSubmit, setValue, getValues, error } = form + const submit = handleSubmit(() => {}) + + const key = `PaymentMethodOptions_${code}_${brandCode}` + + /** To use an external Pay button we register the current form to be handled there as well. */ + useFormCompose({ form, step, submit, key }) + + return ( + +
+ + + + + + +
+ ) +} diff --git a/packages/magento-payment-adyen/methods/adyen_cc/adyenCcExpandMethods.ts b/packages/magento-payment-adyen/methods/adyen_cc/adyenCcExpandMethods.ts new file mode 100644 index 00000000000..481847f809e --- /dev/null +++ b/packages/magento-payment-adyen/methods/adyen_cc/adyenCcExpandMethods.ts @@ -0,0 +1,26 @@ +import { ExpandPaymentMethods } from '@graphcommerce/magento-cart-payment-method' +import { filterNonNullableKeys } from '@graphcommerce/next-ui' +import { UseAdyenPaymentMethodsDocument } from '../../hooks/UseAdyenPaymentMethods.gql' + +export const nonNullable = (value: T): value is NonNullable => + value !== null && value !== undefined + +export const adyenCcExpandMethods: ExpandPaymentMethods = async (available, context) => { + if (!context.id) return [] + + const methods = ( + await context.client.query({ + query: UseAdyenPaymentMethodsDocument, + variables: { cartId: context.id }, + }) + ).data.adyenPaymentMethods?.paymentMethodsResponse?.paymentMethods + + return filterNonNullableKeys(methods, ['name', 'type']) + .map((method) => ({ + title: method.name, + code: available.code, + child: method.type, + valid: true, + })) + .filter((method) => method.child === 'scheme') +} diff --git a/packages/magento-payment-adyen/methods/adyen_cc/index.ts b/packages/magento-payment-adyen/methods/adyen_cc/index.ts new file mode 100644 index 00000000000..7929a3db3e7 --- /dev/null +++ b/packages/magento-payment-adyen/methods/adyen_cc/index.ts @@ -0,0 +1,13 @@ +import { PaymentModule } from '@graphcommerce/magento-cart-payment-method' +import { AdyenPaymentActionCard } from '../../components/AdyenPaymentActionCard/AdyenPaymentActionCard' +import { PaymentButton } from './PaymentButton' +import { PaymentMethodOptions } from './PaymentMethodOptions' +import { adyenCcExpandMethods } from './adyenCcExpandMethods' + +export const adyen_cc = { + PaymentOptions: PaymentMethodOptions, + PaymentPlaceOrder: () => null, + PaymentActionCard: AdyenPaymentActionCard, + expandMethods: adyenCcExpandMethods, + PaymentButton, +} as PaymentModule diff --git a/packages/magento-payment-adyen/methods/adyen_hpp/AdyenHppPaymentOptionsAndPlaceOrder.graphql b/packages/magento-payment-adyen/methods/adyen_hpp/AdyenHppPaymentOptionsAndPlaceOrder.graphql new file mode 100644 index 00000000000..342107c7b40 --- /dev/null +++ b/packages/magento-payment-adyen/methods/adyen_hpp/AdyenHppPaymentOptionsAndPlaceOrder.graphql @@ -0,0 +1,27 @@ +mutation AdyenHppPaymentOptionsAndPlaceOrder( + $cartId: String! + $brandCode: String! + $stateData: String! +) { + setPaymentMethodOnCart( + input: { + cart_id: $cartId + payment_method: { + code: "adyen_hpp" + adyen_additional_data_hpp: { brand_code: $brandCode, stateData: $stateData } + } + } + ) { + cart { + ...PaymentMethodUpdated + } + } + placeOrder(input: { cart_id: $cartId }) { + order { + order_number + adyen_payment_status { + ...AdyenPaymentResponse + } + } + } +} diff --git a/packages/magento-payment-adyen/methods/adyen_hpp/ApplePay.tsx b/packages/magento-payment-adyen/methods/adyen_hpp/ApplePay.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/magento-payment-adyen/methods/adyen_hpp/GooglePay.tsx b/packages/magento-payment-adyen/methods/adyen_hpp/GooglePay.tsx new file mode 100644 index 00000000000..a7939582d19 --- /dev/null +++ b/packages/magento-payment-adyen/methods/adyen_hpp/GooglePay.tsx @@ -0,0 +1,245 @@ +import { styled } from '@mui/material' +import Script from 'next/script' +import { Trans } from '@lingui/react' +import { ErrorSnackbar, Button } from '@graphcommerce/next-ui' +import AdyenCheckout from '@adyen/adyen-web' +import GooglePayElement from '@adyen/adyen-web/dist/types/components/GooglePay' +import { usePaymentMethodContext } from '@graphcommerce/magento-cart-payment-method' +import { useEffect, useRef, useState } from 'react' +import { useFormCompose } from '@graphcommerce/react-hook-form' +import { useFormGqlMutationCart } from '@graphcommerce/magento-cart' +import { useAdyenPaymentMethod } from '../../hooks/useAdyenPaymentMethod' +import { useAdyenCartLock } from '../../hooks/useAdyenCartLock' +import { ResultCodeEnum, isResultCodeEnum } from '../../hooks/useAdyenHandlePaymentResponse' +import { + AdyenHppPaymentOptionsAndPlaceOrderMutation, + AdyenHppPaymentOptionsAndPlaceOrderMutationVariables, + AdyenHppPaymentOptionsAndPlaceOrderDocument, +} from './AdyenHppPaymentOptionsAndPlaceOrder.gql' +import { refresh } from '../../lib/common' + +import '@adyen/adyen-web/dist/adyen.css' +import { useAdyenCheckoutConfig } from '../../hooks/useAdyenCheckoutConfig' + +const getResultCode = (result): ResultCodeEnum => { + return result?.data?.placeOrder?.order.adyen_payment_status?.resultCode && + isResultCodeEnum(result?.data?.placeOrder?.order.adyen_payment_status?.resultCode) + ? result?.data?.placeOrder?.order.adyen_payment_status?.resultCode + : ResultCodeEnum.Error +} + +const getEnvironment = (environment: string): string => { + let result = 'PRODUCTION' + if (environment.toLowerCase() === 'test') { + result = 'TEST' + } + return result +} + +const GooglePayContainer = styled('div')(({ theme }) => ({ + margin: '0 auto', + display: 'block', + width: '40%', + [theme.breakpoints.down('md')]: { + width: '100%', + }, +})) + +export default function GooglePay(props) { + const { step, code, brandCode, cart } = props + + const paymentContainer = useRef(null) + const stateData = useRef(undefined) + const action = useRef(undefined) + const orderNumber = useRef('') + const [googlePay, setGooglePay] = useState(undefined) + const [error, setError] = useState(false) + const [loaded, setLoaded] = useState(false) + + const conf = useAdyenPaymentMethod(brandCode) + const { selectedMethod, onSuccess } = usePaymentMethodContext() + + let ignore = false + const adyenCheckoutConfig = useAdyenCheckoutConfig() + + const createCheckout = async () => { + console.info('create checkout') + const checkout = await AdyenCheckout({ + ...adyenCheckoutConfig, + onSubmit: (state) => { + if (state.isValid) { + const data = JSON.stringify(state.data) + stateData.current = data + setValue('stateData', data) + submit() + console.info('onSubmit set stateData done') + } else { + setError(true) + } + }, + onError: (error: any, _component: any) => { + console.error(error) + setError(true) + }, + }) + + // The 'ignore' flag is used to avoid double re-rendering caused by React 18 StrictMode + // More about it here: https://beta.reactjs.org/learn/synchronizing-with-effects#fetching-data + if (paymentContainer.current && !ignore) { + console.info('creating checkout at the end') + const options = { + amount: { + value: parseFloat(cart?.prices?.grand_total?.value) * 100, + currency: cart?.prices?.grand_total?.currency, + }, + configuration: { + merchantName: process.env.NEXT_PUBLIC_ADYEN_MERCHANT_NAME, + merchantId: process.env.NEXT_PUBLIC_ADYEN_GOOGLE_PAY_MERCHANT_ID, + gatewayMerchantId: process.env.NEXT_PUBLIC_ADYEN_MERCHANT_ACCOUNT, + }, + environment: getEnvironment(String(process.env.NEXT_PUBLIC_ADYEN_ENVIRONMENT)), + countryCode: process.env.NEXT_PUBLIC_ADYEN_COUNTRY_CODE, + buttonType: 'checkout', + buttonSizeMode: 'fill', + } + + if (googlePay === undefined) { + // @ts-ignore + const component: GooglePayElement = checkout.create(brandCode, options) + + component + .isAvailable() + .then(() => { + // @ts-ignore + setGooglePay(component.mount(paymentContainer.current)) + }) + .catch((e) => { + //Google Pay is not available + console.info(e) + }) + } else { + console.info(`remounting ${brandCode}`) + googlePay.unmount() + // @ts-ignore + const component: GooglePayElement = checkout.create(brandCode, options) + component + .isAvailable() + .then(() => { + // @ts-ignore + setGooglePay(component.mount(paymentContainer.current)) + }) + .catch((e) => { + //Google Pay is not available + console.info(e) + }) + } + } + } + + useEffect(() => { + if (!conf?.paymentMethodsResponse || !paymentContainer.current || loaded === false) { + console.info('loaded', loaded) + return + } + + createCheckout() + + return () => { + ignore = true + } + }, [conf?.paymentMethodsResponse, loaded]) + + // Set Adyen client data on payment and place order + const [, lock] = useAdyenCartLock() + const form = useFormGqlMutationCart< + AdyenHppPaymentOptionsAndPlaceOrderMutation, + AdyenHppPaymentOptionsAndPlaceOrderMutationVariables & { issuer?: string } + >(AdyenHppPaymentOptionsAndPlaceOrderDocument, { + onBeforeSubmit: async (vars) => { + // @ts-ignore + await lock({ method: selectedMethod.code, adyen: '1' }) + + const data = await new Promise((resolve) => { + ;(function waitFor() { + if (stateData.current !== undefined) return resolve(stateData.current) + setTimeout(waitFor, 30) + })() + }) + + return { + ...vars, + stateData: data, + brandCode, + } + }, + onComplete: async (result) => { + console.debug('place order result:', result) + const merchantReference = result.data?.placeOrder?.order.order_number + if (merchantReference !== undefined && merchantReference !== null) { + orderNumber.current = merchantReference + } + + const isAction = result?.data?.placeOrder?.order.adyen_payment_status?.action + if (isAction !== undefined && isAction !== null) { + action.current = isAction + } + + const resultCode = getResultCode(result) + + // Case 1: Place Order failure -> Show error message and remount googlePay component + if (result.errors || !merchantReference || !selectedMethod?.code) { + console.info('recreating component') + createCheckout() + return + } + + // Case 2: Authorised successfully + if (resultCode == ResultCodeEnum.Authorised) { + console.info(`${brandCode} payment success`) + await onSuccess(merchantReference) + } + }, + }) + + const { register, handleSubmit, setValue } = form + const submit = handleSubmit(() => { + console.info('handleSubmit') + }) + + const key = `PaymentMethodOptions_${code}_${brandCode}` + + /** To use an external Pay button we register the current form to be handled there as well. */ + useFormCompose({ form, step, submit, key }) + + // if (error) return
Failed to load
+ if (!conf?.paymentMethodsResponse) return
Loading...
+ + return ( +
+