Skip to content

Commit

Permalink
Add Apple Pay validate merchant implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
typeofweb committed Jan 29, 2024
1 parent 459d9ff commit 6f1751a
Show file tree
Hide file tree
Showing 6 changed files with 256 additions and 6 deletions.
2 changes: 1 addition & 1 deletion example/src/payment-methods.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export const PaymentMethods = () => {
return;
}

const applePaySession = new ApplePaySession(6, {
const applePaySession = new ApplePaySession(14, {
countryCode,
currencyCode: checkoutResponse.checkout.totalPrice.gross.currency,
merchantCapabilities: ["supports3DS", "supportsCredit", "supportsDebit"],
Expand Down
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;
};
47 changes: 47 additions & 0 deletions src/modules/applepay/httpsPromisify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { httpsPromisified } from "./httpsPromisify";
import { setupRecording } from "@/__tests__/polly";

describe("httpsPromisified", () => {
setupRecording({});

const TEST_URL = "https://test-http.local/";

it("should make a GET request", async (ctx) => {
ctx.polly?.server.get(TEST_URL).intercept((_req, res) => {
res.json({
data: {},
});
});

await expect(httpsPromisified(TEST_URL, {}, "")).resolves.toMatchInlineSnapshot(`
{
"body": "{\\"data\\":{}}",
"headers": {
"content-type": "application/json; charset=utf-8",
},
"statusCode": 200,
}
`);
});

it("should make a POST request", async (ctx) => {
ctx.polly?.server.post(TEST_URL).intercept((req, res) => {
expect(req.jsonBody()).toEqual({ aaa: 123 });
res.json({
data: {},
});
});

await expect(httpsPromisified(TEST_URL, { method: "POST" }, JSON.stringify({ aaa: 123 })))
.resolves.toMatchInlineSnapshot(`
{
"body": "{\\"data\\":{}}",
"headers": {
"content-type": "application/json; charset=utf-8",
},
"statusCode": 200,
}
`);
});
});
44 changes: 44 additions & 0 deletions src/modules/applepay/httpsPromisify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import https from "node:https";
import { type IncomingHttpHeaders } from "node:http2";
import { isNotNullish } from "@/lib/utils";
import { HttpRequestError } from "@/errors";

type HttpsPromisifiedResult = { statusCode: number; headers: IncomingHttpHeaders; body: string };

// based on https://gist.github.com/ktheory/df3440b01d4b9d3197180d5254d7fb65
export const httpsPromisified = (url: string | URL, options: https.RequestOptions, data: string) =>
new Promise<HttpsPromisifiedResult>((resolve, reject) => {
try {
const req = https.request(url, options, (res) => {
let body = "";

res.on("data", (chunk) => (body += chunk));
res.on("error", reject);
res.on("end", () => {
const { statusCode, headers } = res;

// @TODO: how to test failing requests
/* c8 ignore start */
if (isNotNullish(statusCode) && statusCode !== 0) {
return resolve({ statusCode, headers, body });
} else {
return reject(
new HttpRequestError("Http Request Error", { props: { statusCode, body, headers } }),
);
/* c8 ignore stop */
}
});
});

req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject("Timeout");
});
req.setTimeout(20_000); // Saleor sync webhook timeout is 20s
req.write(data, "utf-8");
req.end();
} catch (e) {
reject(e);
}
});
27 changes: 22 additions & 5 deletions src/modules/authorize-net/gateways/apple-pay-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@ import {
type PaymentGatewayInitializeSessionEventFragment,
type TransactionInitializeSessionEventFragment,
} from "generated/graphql";
import * as ApplePay from "@/modules/applepay/applepay";

const ApiContracts = AuthorizeNet.APIContracts;

// feel free to migrate it to JSON schema
export const applePayPaymentGatewayResponseDataSchema = z.object({});
export const applePayPaymentGatewayInitializeDataSchema = gatewayUtils.createGatewayDataSchema(
"applePay",
z.object({
validationURL: z.string(),
}),
);

export const applePayPaymentGatewayResponseDataSchema = z.object({
applePayMerchantSession: z.unknown(),
});
type ApplePayPaymentGatewayData = z.infer<typeof applePayPaymentGatewayResponseDataSchema>;

export const applePayTransactionInitializeDataSchema = gatewayUtils.createGatewayDataSchema(
Expand All @@ -27,10 +35,19 @@ export const applePayTransactionInitializeDataSchema = gatewayUtils.createGatewa

export class ApplePayGateway implements PaymentGateway {
async initializePaymentGateway(
_payload: PaymentGatewayInitializeSessionEventFragment,
payload: PaymentGatewayInitializeSessionEventFragment,
): Promise<ApplePayPaymentGatewayData> {
// todo: put everything that client needs to initialize apple pay here
return {};
const applePayData = applePayPaymentGatewayInitializeDataSchema.parse(payload.data);
const applePayMerchantSession = await ApplePay.validateMerchant({
validationURL: applePayData.data.validationURL,
merchantName: "@todo",
merchantIdentifier: "@todo",
domain: "@todo",
applePayCertificate: "@todo",
});
return {
applePayMerchantSession,
};
}

private buildTransactionRequest(
Expand Down

0 comments on commit 6f1751a

Please sign in to comment.