From ed120353be20ac44aa0699b5dd3653e745e68e3b Mon Sep 17 00:00:00 2001 From: Adrian Pilarczyk Date: Fri, 26 Jan 2024 15:30:28 +0100 Subject: [PATCH] add paypal --- example/src/accept-hosted-form.tsx | 23 ++-- example/src/lib/checkoutIdUtils.ts | 25 +++++ .../[transactionId]/paypal/continue/index.tsx | 66 +++++++++++ example/src/pages/cart/index.tsx | 16 +-- example/src/pages/failure/index.tsx | 11 ++ example/src/pages/index.tsx | 3 +- example/src/pages/success/index.tsx | 10 +- example/src/payment-methods.tsx | 15 ++- example/src/paypal-form.tsx | 100 +++++++++++++++++ .../TransactionInitializeSessionEvent.graphql | 10 +- .../TransactionProcessAction.graphql | 6 + .../TransactionProcessSessionEvent.graphql | 40 +------ .../authorize-transaction-builder.ts | 41 ++++--- .../client/authorize-net-client.ts | 24 +++- .../client/create-transaction.ts | 21 +++- .../client/customer-profile-client.ts | 4 +- .../client/hosted-payment-page-client.ts | 2 +- .../client/transaction-details-client.ts | 6 +- .../gateways/accept-hosted-gateway.ts | 62 ++++------- .../gateways/accept-hosted-schema.ts | 30 +++++ .../gateways/apple-pay-gateway.ts | 11 +- .../authorize-net/gateways/paypal-gateway.ts | 104 ++++++++++++++---- .../authorize-net/gateways/paypal-schema.ts | 30 +++++ .../payment-gateway-initialize-session.ts | 28 +++-- .../transaction-initialize-session.ts | 48 +++----- .../webhooks/transaction-process-session.ts | 13 +-- .../webhooks/transaction-refund-requested.ts | 9 +- .../webhooks/webhook-manager-service.ts | 4 +- .../payment-gateway-initialize-session.ts | 41 +++---- 29 files changed, 556 insertions(+), 247 deletions(-) create mode 100644 example/src/lib/checkoutIdUtils.ts create mode 100644 example/src/pages/[transactionId]/paypal/continue/index.tsx create mode 100644 example/src/pages/failure/index.tsx create mode 100644 example/src/paypal-form.tsx create mode 100644 graphql/fragments/TransactionProcessAction.graphql create mode 100644 src/modules/authorize-net/gateways/accept-hosted-schema.ts create mode 100644 src/modules/authorize-net/gateways/paypal-schema.ts diff --git a/example/src/accept-hosted-form.tsx b/example/src/accept-hosted-form.tsx index 4ee02b5..7ccdfc8 100644 --- a/example/src/accept-hosted-form.tsx +++ b/example/src/accept-hosted-form.tsx @@ -11,8 +11,9 @@ import { TransactionProcessMutationVariables, } from "../generated/graphql"; import { authorizeNetAppId } from "./lib/common"; -import { getCheckoutId } from "./pages/cart"; + import { useRouter } from "next/router"; +import { checkoutIdUtils } from "./lib/checkoutIdUtils"; const acceptHostedTransactionResponseSchema = z.object({ transId: z.string(), @@ -21,14 +22,16 @@ const acceptHostedTransactionResponseSchema = z.object({ const authorizeEnvironmentSchema = z.enum(["sandbox", "production"]); const acceptHostedTransactionInitializeResponseDataSchema = z.object({ - formToken: z.string().min(1), - environment: authorizeEnvironmentSchema, + type: z.literal("acceptHosted"), + data: z.object({ + formToken: z.string().min(1), + environment: authorizeEnvironmentSchema, + }), }); -type AcceptHostedData = z.infer; +type AcceptHostedData = z.infer["data"]; export function AcceptHostedForm() { - const checkoutId = getCheckoutId(); const router = useRouter(); const [acceptData, setAcceptData] = React.useState(); const [transactionId, setTransactionId] = React.useState(); @@ -43,6 +46,12 @@ export function AcceptHostedForm() { ); const getAcceptData = React.useCallback(async () => { + const checkoutId = checkoutIdUtils.get(); + + if (!checkoutId) { + throw new Error("Checkout id not found"); + } + const initializeTransactionResponse = await initializeTransaction({ variables: { checkoutId, @@ -76,9 +85,9 @@ export function AcceptHostedForm() { console.log(data); - const nextAcceptData = acceptHostedTransactionInitializeResponseDataSchema.parse(data); + const { data: nextAcceptData } = acceptHostedTransactionInitializeResponseDataSchema.parse(data); setAcceptData(nextAcceptData); - }, [initializeTransaction, checkoutId]); + }, [initializeTransaction]); React.useEffect(() => { getAcceptData(); diff --git a/example/src/lib/checkoutIdUtils.ts b/example/src/lib/checkoutIdUtils.ts new file mode 100644 index 0000000..f09ee32 --- /dev/null +++ b/example/src/lib/checkoutIdUtils.ts @@ -0,0 +1,25 @@ +import React from "react"; + +export const checkoutIdUtils = { + set: (id: string) => localStorage.setItem("checkoutId", id), + get: () => { + const checkoutId = localStorage.getItem("checkoutId"); + + if (!checkoutId) { + throw new Error("Checkout ID not found"); + } + + return checkoutId; + }, +}; + +export const useGetCheckoutId = () => { + const [checkoutId, setCheckoutId] = React.useState(null); + + React.useEffect(() => { + const checkoutId = checkoutIdUtils.get(); + setCheckoutId(checkoutId); + }, []); + + return checkoutId; +}; diff --git a/example/src/pages/[transactionId]/paypal/continue/index.tsx b/example/src/pages/[transactionId]/paypal/continue/index.tsx new file mode 100644 index 0000000..c04edbc --- /dev/null +++ b/example/src/pages/[transactionId]/paypal/continue/index.tsx @@ -0,0 +1,66 @@ +import { useMutation } from "@apollo/client"; +import gql from "graphql-tag"; +import React from "react"; +import { + TransactionProcessMutation, + TransactionProcessMutationVariables, + TransactionProcessDocument, +} from "../../../../../generated/graphql"; +import { useRouter } from "next/router"; + +type Status = "idle" | "loading" | "success"; + +const PaypalContinuePage = () => { + const [processTransaction] = useMutation( + gql(TransactionProcessDocument.toString()), + ); + const router = useRouter(); + const isCalled = React.useRef(false); + const [status, setStatus] = React.useState("idle"); + + const continueTransaction = React.useCallback( + async ({ payerId, transactionId }: { payerId: string; transactionId: string }) => { + setStatus("loading"); + const response = await processTransaction({ + variables: { + transactionId, + data: { + type: "paypal", + data: { + payerId, + }, + }, + }, + }); + + isCalled.current = true; + + if (response.data?.transactionProcess?.transactionEvent?.type !== "AUTHORIZATION_SUCCESS") { + throw new Error("Transaction failed"); + } + + setStatus("success"); + }, + [processTransaction], + ); + + React.useEffect(() => { + const payerId = router.query.PayerID?.toString(); + const rawTransactionId = router.query.transactionId?.toString(); + setStatus("idle"); + + if (payerId && rawTransactionId && !isCalled.current) { + const transactionId = atob(rawTransactionId); + continueTransaction({ payerId, transactionId }); + } + }, [continueTransaction, router.query.PayerID, router.query.transactionId]); + + return ( +
+ {status === "loading" &&
Processing transaction...
} + {status === "success" &&
You successfully paid with PayPal 🎺
} +
+ ); +}; + +export default PaypalContinuePage; diff --git a/example/src/pages/cart/index.tsx b/example/src/pages/cart/index.tsx index 6c46c10..8561a59 100644 --- a/example/src/pages/cart/index.tsx +++ b/example/src/pages/cart/index.tsx @@ -4,27 +4,17 @@ import { GetCheckoutByIdQuery, GetCheckoutByIdQueryVariables, } from "../../../generated/graphql"; +import { useGetCheckoutId } from "../../lib/checkoutIdUtils"; import { authorizeNetAppId } from "../../lib/common"; -import React from "react"; import { PaymentMethods } from "../../payment-methods"; -export function getCheckoutId() { - const checkoutId = typeof sessionStorage === "undefined" ? undefined : sessionStorage.getItem("checkoutId"); - - if (!checkoutId) { - throw new Error("Checkout ID not found in sessionStorage"); - } - - return checkoutId; -} - export default function CartPage() { - const checkoutId = getCheckoutId(); + const checkoutId = useGetCheckoutId(); const { data: checkoutResponse, loading: checkoutLoading } = useQuery< GetCheckoutByIdQuery, GetCheckoutByIdQueryVariables - >(gql(GetCheckoutByIdDocument.toString()), { variables: { id: checkoutId } }); + >(gql(GetCheckoutByIdDocument.toString()), { variables: { id: checkoutId ?? "" }, skip: !checkoutId }); const isAuthorizeAppInstalled = checkoutResponse?.checkout?.availablePaymentGateways.some( (gateway) => gateway.id === authorizeNetAppId, diff --git a/example/src/pages/failure/index.tsx b/example/src/pages/failure/index.tsx new file mode 100644 index 0000000..9dc2001 --- /dev/null +++ b/example/src/pages/failure/index.tsx @@ -0,0 +1,11 @@ +import React from "react"; + +const FailurePage = () => { + return ( +
+

Something went wrong

+
+ ); +}; + +export default FailurePage; diff --git a/example/src/pages/index.tsx b/example/src/pages/index.tsx index 85f6667..a396512 100644 --- a/example/src/pages/index.tsx +++ b/example/src/pages/index.tsx @@ -13,6 +13,7 @@ import { UpdateDeliveryMutationVariables, UpdateDeliveryDocument, } from "../../generated/graphql"; +import { checkoutIdUtils } from "../lib/checkoutIdUtils"; export default function Page() { const { data, loading } = useQuery( @@ -53,7 +54,7 @@ export default function Page() { await updateDelivery({ variables: { checkoutId: response.data.checkoutCreate.checkout.id, methodId } }); - sessionStorage.setItem("checkoutId", response.data.checkoutCreate.checkout.id); + checkoutIdUtils.set(response.data.checkoutCreate.checkout.id); return router.push("/cart"); }; diff --git a/example/src/pages/success/index.tsx b/example/src/pages/success/index.tsx index 28f163c..cc39e4e 100644 --- a/example/src/pages/success/index.tsx +++ b/example/src/pages/success/index.tsx @@ -2,20 +2,24 @@ import { useMutation } from "@apollo/client"; import gql from "graphql-tag"; import React from "react"; import { + CheckoutCompleteDocument, CheckoutCompleteMutation, CheckoutCompleteMutationVariables, - CheckoutCompleteDocument, } from "../../../generated/graphql"; -import { getCheckoutId } from "../cart"; +import { useGetCheckoutId } from "../../lib/checkoutIdUtils"; const SuccessPage = () => { - const checkoutId = getCheckoutId(); + const checkoutId = useGetCheckoutId(); const [isCompleted, setIsCompleted] = React.useState(false); const [completeCheckout] = useMutation( gql(CheckoutCompleteDocument.toString()), ); const checkoutCompleteHandler = async () => { + if (!checkoutId) { + throw new Error("Checkout id not found"); + } + const response = await completeCheckout({ variables: { checkoutId, diff --git a/example/src/payment-methods.tsx b/example/src/payment-methods.tsx index c568711..9f38f7b 100644 --- a/example/src/payment-methods.tsx +++ b/example/src/payment-methods.tsx @@ -9,7 +9,8 @@ import { import { authorizeNetAppId } from "./lib/common"; import { AcceptHostedForm } from "./accept-hosted-form"; -import { getCheckoutId } from "./pages/cart"; +import { checkoutIdUtils } from "./lib/checkoutIdUtils"; +import { PayPalForm } from "./paypal-form"; const acceptHostedPaymentGatewaySchema = z.object({}); @@ -30,8 +31,6 @@ export const PaymentMethods = () => { const [isLoading, setIsLoading] = React.useState(false); const [paymentMethods, setPaymentMethods] = React.useState(); - const checkoutId = getCheckoutId(); - const [initializePaymentGateways] = useMutation< PaymentGatewayInitializeMutation, PaymentGatewayInitializeMutationVariables @@ -39,6 +38,12 @@ export const PaymentMethods = () => { const getPaymentGateways = React.useCallback(async () => { setIsLoading(true); + const checkoutId = checkoutIdUtils.get(); + + if (!checkoutId) { + throw new Error("Checkout id not found"); + } + const response = await initializePaymentGateways({ variables: { appId: authorizeNetAppId, @@ -64,7 +69,7 @@ export const PaymentMethods = () => { } setPaymentMethods(data); - }, [initializePaymentGateways, checkoutId]); + }, [initializePaymentGateways]); React.useEffect(() => { getPaymentGateways(); @@ -87,7 +92,7 @@ export const PaymentMethods = () => { )} {paymentMethods?.paypal !== undefined && (
  • - +
  • )} diff --git a/example/src/paypal-form.tsx b/example/src/paypal-form.tsx new file mode 100644 index 0000000..df30da6 --- /dev/null +++ b/example/src/paypal-form.tsx @@ -0,0 +1,100 @@ +import { useMutation } from "@apollo/client"; +import gql from "graphql-tag"; +import Script from "next/script"; +import React from "react"; +import { z } from "zod"; +import { + TransactionInitializeDocument, + TransactionInitializeMutation, + TransactionInitializeMutationVariables, +} from "../generated/graphql"; +import { checkoutIdUtils } from "./lib/checkoutIdUtils"; +import { authorizeNetAppId } from "./lib/common"; + +declare global { + interface Window { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + paypal: any; + } +} + +const paypalTransactionResponseDataSchema = z.object({ + secureAcceptanceUrl: z.string().min(1).optional(), + error: z + .object({ + message: z.string().min(1), + }) + .optional(), +}); + +/** + * This form uses PayPal's legacy Express Checkout integration + * https://developer.paypal.com/docs/archive/express-checkout/in-context/javascript-advanced-settings/ */ + +export const PayPalForm = () => { + const [isLoading, setIsLoading] = React.useState(false); + const [initializeTransaction] = useMutation< + TransactionInitializeMutation, + TransactionInitializeMutationVariables + >(gql(TransactionInitializeDocument.toString())); + + function onLoad() { + if (typeof window !== "undefined" && window.paypal) { + window.paypal.checkout.setup(" ", { + environment: "sandbox", + container: "paypalButton", + click: () => { + getPayPalAcceptanceUrl(); + }, + }); + } + } + + const getPayPalAcceptanceUrl = async () => { + setIsLoading(true); + const checkoutId = checkoutIdUtils.get(); + + const initializeTransactionResponse = await initializeTransaction({ + variables: { + checkoutId, + paymentGateway: authorizeNetAppId, + data: { + type: "paypal", + data: {}, + }, + }, + }); + + if (initializeTransactionResponse.data?.transactionInitialize?.errors?.length) { + throw new Error("Failed to initialize transaction"); + } + + const data = initializeTransactionResponse.data?.transactionInitialize?.data; + + if (!data) { + throw new Error("Data not found on transaction initialize response"); + } + + const { secureAcceptanceUrl, error } = paypalTransactionResponseDataSchema.parse(data); + + if (error) { + throw new Error(error.message); + } + + if (!secureAcceptanceUrl) { + throw new Error("Secure acceptance url not found"); + } + + setIsLoading(false); + window.open(secureAcceptanceUrl, "_self"); + }; + + return ( + <> + {/* We need to load this before we execute any code */} +