Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/applepay #75

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ AUTHORIZE_ENVIRONMENT="sandbox"
AUTHORIZE_SALEOR_CHANNEL_SLUG="default-channel"
AUTHORIZE_PAYMENT_FORM_URL=
AUTHORIZE_SIGNATURE_KEY=
APP_API_BASE_URL=
APP_API_BASE_URL=
1 change: 1 addition & 0 deletions example/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
SALEOR_API_URL=
NEXT_PUBLIC_SALEOR_API_URL=
16 changes: 16 additions & 0 deletions example/graphql/GetCheckoutById.graphql
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
query GetCheckoutById($id: ID!) {
checkout(id: $id) {
id
totalPrice {
gross {
amount
currency
}
}
billingAddress {
country {
code
}
}
shippingAddress {
country {
code
}
}
lines {
id
variant {
Expand Down
1 change: 1 addition & 0 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@graphql-codegen/client-preset": "4.1.0",
"@graphql-typed-document-node/core": "3.2.0",
"@next/env": "13.4.19",
"@types/applepayjs": "14.0.3",
"@types/node": "20.6.0",
"@types/react": "18.2.21",
"@types/react-dom": "18.2.7",
Expand Down
15 changes: 11 additions & 4 deletions example/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion example/src/accept-hosted-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function AcceptHostedForm() {
throw new Error("No data found in response");
}

console.log(data);
console.log({ data });

const nextAcceptData = acceptHostedTransactionInitializeResponseDataSchema.parse(data);
setAcceptData(nextAcceptData);
Expand Down
10 changes: 10 additions & 0 deletions example/src/global.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

.apple-pay-button {
display: inline-block;
-webkit-appearance: -apple-pay-button;
-apple-pay-button-type: plain;
@apply rounded-md mt-2 h-[46px] w-36 block indent-[-9999px];
}
.apple-pay-button-black {
-apple-pay-button-style: black;
}
58 changes: 54 additions & 4 deletions example/src/payment-methods.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { gql, useMutation } from "@apollo/client";
import { gql, useMutation, useQuery } from "@apollo/client";
import React from "react";
import { z } from "zod";
import {
GetCheckoutByIdDocument,
GetCheckoutByIdQuery,
GetCheckoutByIdQueryVariables,
PaymentGatewayInitializeDocument,
PaymentGatewayInitializeMutation,
PaymentGatewayInitializeMutationVariables,
Expand All @@ -15,7 +18,7 @@ const acceptHostedPaymentGatewaySchema = z.object({});

export type AcceptHostedData = z.infer<typeof acceptHostedPaymentGatewaySchema>;

// currently, Payment Gateway Initialize doesnt return any config data
// currently, Payment Gateway Initialize doesn't return any config data
const dataSchema = z.object({
acceptHosted: z.unknown().optional(),
applePay: z.unknown().optional(),
Expand All @@ -31,6 +34,10 @@ export const PaymentMethods = () => {
const [paymentMethods, setPaymentMethods] = React.useState<PaymentMethods>();

const checkoutId = getCheckoutId();
const { data: checkoutResponse } = useQuery<GetCheckoutByIdQuery, GetCheckoutByIdQueryVariables>(
gql(GetCheckoutByIdDocument.toString()),
{ variables: { id: checkoutId } },
);

const [initializePaymentGateways] = useMutation<
PaymentGatewayInitializeMutation,
Expand Down Expand Up @@ -70,6 +77,43 @@ export const PaymentMethods = () => {
getPaymentGateways();
}, [getPaymentGateways]);

const [isApplePayAvailable, setIsApplePayAvailable] = React.useState(false);

React.useEffect(() => {
if (
typeof window === "undefined" ||
typeof ApplePaySession === "undefined" ||
!checkoutResponse?.checkout
) {
return;
}
setIsApplePayAvailable(ApplePaySession.canMakePayments());
}, [checkoutResponse]);

const initializeApplePay = () => {
if (!checkoutResponse?.checkout) {
return;
}
const countryCode =
checkoutResponse.checkout.billingAddress?.country.code ||
checkoutResponse.checkout.shippingAddress?.country.code;
if (!countryCode) {
return;
}

const applePaySession = new ApplePaySession(14, {
countryCode,
currencyCode: checkoutResponse.checkout.totalPrice.gross.currency,
merchantCapabilities: ["supports3DS", "supportsCredit", "supportsDebit"],
supportedNetworks: ["amex", "masterCard", "maestro", "visa"],
total: {
label: "Gross Total Amount",
amount: checkoutResponse.checkout.totalPrice.gross.amount.toFixed(2),
},
});
applePaySession.begin();
};

return (
<div>
<h2>Payment Methods</h2>
Expand All @@ -80,9 +124,15 @@ export const PaymentMethods = () => {
<AcceptHostedForm />
</li>
)}
{paymentMethods?.applePay !== undefined && (
{isApplePayAvailable && (
<li>
<button>Apple Pay</button>
<button
type="button"
onClick={initializeApplePay}
className="apple-pay-button apple-pay-button-black"
>
Pay with Apple Pay
</button>
</li>
)}
{paymentMethods?.paypal !== undefined && (
Expand Down
9 changes: 8 additions & 1 deletion example/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@
}
]
},
"include": ["next-env.d.ts", "paypal.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": [
"@types/applepayjs",
"next-env.d.ts",
"paypal.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
},
"type": "module",
"scripts": {
"dev": "pnpm generate && next dev --port 8000",
"dev": "pnpm generate && next dev",
"build": "pnpm generate && next build",
"deploy": "tsx ./src/deploy.ts",
"start": "next start",
Expand Down
Binary file added public/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type IncomingHttpHeaders } from "node:http2";
import { type TRPC_ERROR_CODE_KEY } from "@trpc/server/rpc";
import ModernError from "modern-errors";
import ModernErrorsSerialize from "modern-errors-serialize";
Expand Down Expand Up @@ -41,3 +42,14 @@ export const ReqMissingTokenError = BaseTrpcError.subclass("ReqMissingTokenError
export const ReqMissingAppIdError = BaseTrpcError.subclass("ReqMissingAppIdError", {
props: { trpcCode: "BAD_REQUEST" } as TrpcErrorOptions,
});

export const ApplePayInvalidMerchantDomainError = BaseError.subclass(
"ApplePayInvalidMerchantDomainError",
);
export const ApplePayMissingCertificateError = BaseError.subclass(
"ApplePayMissingCertificateError",
);
export const ApplePayHttpError = BaseError.subclass("ApplePayHttpError");
export const HttpRequestError = BaseError.subclass("HttpRequestError", {
props: {} as { statusCode: number; body: string; headers: IncomingHttpHeaders },
});
130 changes: 130 additions & 0 deletions src/modules/applepay/applepay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import https from "node:https";
import { httpsPromisified } from "./httpsPromisify";
import {
ApplePayHttpError,
ApplePayInvalidMerchantDomainError,
ApplePayMissingCertificateError,
} from "@/errors";
import { type JSONValue } from "@/types";
import { unpackPromise } from "@/lib/utils";
import { createLogger } from "@/lib/logger";

/**
* https://developer.apple.com/documentation/apple_pay_on_the_web/setting_up_your_server#3172427
*/
const applePayValidateMerchantDomains = [
// For production environment:
"apple-pay-gateway.apple.com",
"cn-apple-pay-gateway.apple.com",
// Additional domain names and IP addresses:
"apple-pay-gateway-nc-pod1.apple.com",
"apple-pay-gateway-nc-pod2.apple.com",
"apple-pay-gateway-nc-pod3.apple.com",
"apple-pay-gateway-nc-pod4.apple.com",
"apple-pay-gateway-nc-pod5.apple.com",
"apple-pay-gateway-pr-pod1.apple.com",
"apple-pay-gateway-pr-pod2.apple.com",
"apple-pay-gateway-pr-pod3.apple.com",
"apple-pay-gateway-pr-pod4.apple.com",
"apple-pay-gateway-pr-pod5.apple.com",
"cn-apple-pay-gateway-sh-pod1.apple.com",
"cn-apple-pay-gateway-sh-pod2.apple.com",
"cn-apple-pay-gateway-sh-pod3.apple.com",
"cn-apple-pay-gateway-tj-pod1.apple.com",
"cn-apple-pay-gateway-tj-pod2.apple.com",
"cn-apple-pay-gateway-tj-pod3.apple.com",
// For sandbox testing only:
"apple-pay-gateway-cert.apple.com",
"cn-apple-pay-gateway-cert.apple.com",
] as const;

export const validateMerchant = async ({
validationURL,
merchantName,
merchantIdentifier,
domain,
applePayCertificate,
}: {
/** Fully qualified validation URL that you receive in `onvalidatemerchant` */
validationURL: string;
/** A string of 64 or fewer UTF-8 characters containing the canonical name for your store, suitable for display. This needs to remain a consistent value for the store and shouldn’t contain dynamic values such as incrementing order numbers. Don’t localize the name. */
merchantName: string;
/** Your Apple merchant identifier (`partnerInternalMerchantIdentifier`) as described in https://developer.apple.com/documentation/apple_pay_on_the_web/applepayrequest/2951611-merchantidentifier */
merchantIdentifier: string;
/** Fully qualified domain name associated with your Apple Pay Merchant Identity Certificate. */
domain: string;
/** base64 encoded `apple-pay-cert.pem` file */
applePayCertificate: string;
}) => {
const logger = createLogger({ name: "validateMerchant" });

logger.debug("Received validation URL", { validationURL, merchantName, merchantIdentifier });

if (!applePayCertificate) {
logger.error("Missing Apple Pay Merchant Identity Certificate");
throw new ApplePayMissingCertificateError("Missing Apple Pay Merchant Identity Certificate");
}

const applePayURL = new URL(validationURL);
const applePayDomain = applePayURL.hostname;

logger.debug("Validation URL domain", { applePayDomain });
if (!applePayValidateMerchantDomains.includes(applePayDomain)) {
throw new ApplePayInvalidMerchantDomainError(`Invalid validationURL domain: ${applePayDomain}`);
}

const requestData = {
merchantIdentifier,
displayName: merchantName,
initiative: "web",
initiativeContext: domain,
};

logger.debug("requestData", {
merchantIdentifier,
displayName: merchantName,
initiative: "web",
initiativeContext: domain,
});

const cert = Buffer.from(applePayCertificate, "base64");

const agent = new https.Agent({ cert, key: cert, requestCert: true, rejectUnauthorized: true });

logger.debug("Created authenticated HTTPS agent");

const [requestError, requestResult] = await unpackPromise(
httpsPromisified(
validationURL,
{
method: "POST",
agent,
},
JSON.stringify(requestData),
),
);

if (requestError) {
logger.error("Request failed", { requestError: requestError });
throw requestError;
}

const { body, statusCode } = requestResult;

logger.debug("Request done", { statusCode });

if (statusCode < 200 || statusCode >= 300) {
logger.error("Got error response from Apple Pay", { statusCode });

throw new ApplePayHttpError(body, {
props: {
statusCode,
},
});
}

logger.info("Got successful response from Apple Pay", { statusCode });
const json = JSON.parse(body) as JSONValue;

return json;
};
Loading
Loading