Skip to content
Open
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 package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "processout.js",
"version": "1.9.20",
"version": "1.9.21",
"description": "ProcessOut.js is a JavaScript library for ProcessOut's payment processing API.",
"scripts": {
"build:processout": "tsc -p src/processout && uglifyjs --compress --keep-fnames --ie8 dist/processout.js -o dist/processout.js",
Expand Down
44 changes: 27 additions & 17 deletions src/dynamic-checkout/clients/apple-pay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,18 @@ module ProcessOut {
const applePayPaymentMethodData = this.getApplePayPaymentMethodData(invoiceData)
const supportedNetworks = this.getSupportedNetworks(invoiceData)

const session = this.processOutInstance.applePay.newSession(
{
const dispatchSessionError = (err: any) =>
DynamicCheckoutEventsUtils.dispatchApplePaySessionError(
this.paymentConfig.invoiceId,
err,
this.getApplePayPaymentMethodName(invoiceData),
this.paymentConfig.invoiceDetails.return_url || null,
)

let session: ApplePay

try {
session = this.processOutInstance.applePay.newSession({
total: {
label: invoiceData.name,
amount: invoiceData.amount,
Expand All @@ -80,21 +90,21 @@ module ProcessOut {
merchantCapabilities: applePayPaymentMethodData.merchant_capabilities,
merchantApplePayCertificateId: applePayPaymentMethodData.merchant_id,
applePaySessionVersion: 14,
},
() =>
DynamicCheckoutEventsUtils.dispatchApplePayNewSessionEvent(
this.paymentConfig.invoiceId,
this.getApplePayPaymentMethodName(invoiceData),
this.paymentConfig.invoiceDetails.return_url || null,
),
err =>
DynamicCheckoutEventsUtils.dispatchApplePaySessionError(
this.paymentConfig.invoiceId,
err,
this.getApplePayPaymentMethodName(invoiceData),
this.paymentConfig.invoiceDetails.return_url || null,
),
)
})
} catch (err) {
dispatchSessionError(err)

throw err
}

session.onmerchantvalidated = () =>
DynamicCheckoutEventsUtils.dispatchApplePayNewSessionEvent(
this.paymentConfig.invoiceId,
this.getApplePayPaymentMethodName(invoiceData),
this.paymentConfig.invoiceDetails.return_url || null,
)

session.onmerchantvalidationerror = dispatchSessionError

session.onpaymentauthorizedPostprocess = () => {
DynamicCheckoutEventsUtils.dispatchPaymentSubmittedEvent({
Expand Down
4 changes: 3 additions & 1 deletion src/dynamic-checkout/utils/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,9 @@ module ProcessOut {
const event = DynamicCheckoutEventsUtils.createEvent(
DYNAMIC_CHECKOUT_EVENTS.APPLE_PAY_SESSION_ERROR,
{
...err,
// ProcessOut exceptions carry a string `code`; errors thrown by the Apple Pay SDK only a `name`
error_type: (err && (typeof err.code === "string" ? err.code : err.name)) || null,
error_message: (err && err.message) || null,
invoice_id: invoiceId,
payment_method_name: "apple_pay",
payment_method_display_name: paymentMethodDisplayName,
Expand Down
6 changes: 3 additions & 3 deletions src/processout/applepay-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ module ProcessOut {
/**
* newSession creates a new Apple Pay session
* @param {ApplePayPaymentRequest} req
* @param {callback} onsuccess
* @param {callback} onerror
* @param {callback} onsuccess unused, kept for backward compatibility
* @param {callback} onerror unused, kept for backward compatibility
Comment on lines +57 to +58

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to add @deprecated as well?

As far as I understand, we cannot just remove them, because this constructor function may be used by the external callers directly (i.e. merchants)?

*/
public newSession(req: ApplePayPaymentRequest,
onsuccess: (e: any) => void, onerror?: (e: any) => void): ApplePay {
onsuccess?: (e: any) => void, onerror?: (e: any) => void): ApplePay {

return new ApplePay(this.instance, req);
}
Expand Down
51 changes: 46 additions & 5 deletions src/processout/applepay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,21 @@ module ProcessOut {
*/
public onpaymentauthorizedPostprocess: (event: any) => void;

/**
* onmerchantvalidated can be set to handle the event when the merchant
* was validated and the Apple Pay session is ready to be used
* @type {() => void}
*/
public onmerchantvalidated: () => void;

/**
* onmerchantvalidationerror can be set to handle the event when the
* merchant could not be validated. It is called in addition to (and
* right before) the error handler given to tokenize
* @type {(err: Exception) => void}
*/
public onmerchantvalidationerror: (err: Exception) => void;

/**
* ApplePay constructor
* @param {ProcessOut} instance
Expand Down Expand Up @@ -121,17 +136,25 @@ module ProcessOut {
},
function (data: any, req: XMLHttpRequest, e: Event): void {
if (!data.success) {
t.onerror(new Exception(data.error_code, data.message));
t.session.abort();
} else t.session.completeMerchantValidation(data.session_payload);
t.handleMerchantValidationError(
new Exception(data.error_code, data.message)
);

return;
}

t.session.completeMerchantValidation(data.session_payload);

if (t.onmerchantvalidated) {
t.onmerchantvalidated();
}
},
function (
req: XMLHttpRequest,
e: Event,
errorCode: ApiRequestError
): void {
t.onerror(new Exception(errorCode));
t.session.abort();
t.handleMerchantValidationError(new Exception(errorCode));
}
);
};
Expand Down Expand Up @@ -182,6 +205,24 @@ module ProcessOut {
this.onShippingMethodSelectedHandler.bind(this);
}

/**
* handleMerchantValidationError notifies the optional
* onmerchantvalidationerror handler, then reports the error and aborts
* the session exactly as before, even if that handler throws
* @param {Exception} err
* @return {void}
*/
protected handleMerchantValidationError(err: Exception): void {
try {
if (this.onmerchantvalidationerror) {
this.onmerchantvalidationerror(err);
}
} finally {
this.onerror(err);
this.session.abort();
}
}

/**
* SetHandlers sets the success and error handlers
* @param {(e: any) => void} onsuccess
Expand Down
65 changes: 65 additions & 0 deletions test/dynamic-checkout/wallet-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,69 @@ describe("Apple Pay", () => {
return_url: "https://merchant.test",
})
})

function createClient(newSession: () => unknown) {
const client = new ProcessOut.ApplePayClient(
{ applePay: { newSession } },
createPaymentConfig(),
)
client.getSupportedNetworks = () => []

return client
}

function createSessionCallbacks() {
const session = createClient(() => ({})).createApplePaySession(invoiceData)

return { onSuccess: session.onmerchantvalidated, onError: session.onmerchantvalidationerror }
}

it("does not pass callbacks to the public newSession API", () => {
const newSession = vi.fn((..._args: unknown[]) => ({}))

createClient(newSession).createApplePaySession(invoiceData)

expect(newSession.mock.calls[0]).toHaveLength(1)
expect(dispatchedEvents).toHaveLength(0)
})

it("dispatches apple_pay_session_error and rethrows when the session cannot be constructed", () => {
const sdkError = new TypeError("Invalid version")
const client = createClient(() => {
throw sdkError
})

expect(() => client.createApplePaySession(invoiceData)).toThrow(sdkError)
expect(getEventTypes()).toEqual(["processout_dynamic_checkout_apple_pay_session_error"])
expect(dispatchedEvents[0].detail).toMatchObject({
error_type: "TypeError",
error_message: "Invalid version",
})
})

it("dispatches apple_pay_new_session when the session is created", () => {
createSessionCallbacks().onSuccess()

expect(getEventTypes()).toEqual(["processout_dynamic_checkout_apple_pay_new_session"])
expect(dispatchedEvents[0].detail).toEqual({
payment_method_name: "apple_pay",
payment_method_display_name: "Apple Pay (CAT)",
invoice_id: "iv_test",
return_url: "https://merchant.test",
})
})

it("dispatches apple_pay_session_error with the error code and message", () => {
createSessionCallbacks().onError({ code: "request.validation.error", message: "Bad domain" })

expect(getEventTypes()).toEqual(["processout_dynamic_checkout_apple_pay_session_error"])
expect(dispatchedEvents[0].detail).toEqual({
error_type: "request.validation.error",
error_message: "Bad domain",
payment_method_name: "apple_pay",
payment_method_display_name: "Apple Pay (CAT)",
invoice_id: "iv_test",
return_url: "https://merchant.test",
})
})
})
144 changes: 144 additions & 0 deletions test/processout/applepay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, expect, it, vi } from "vitest"
import { loadApplePay } from "../support/loadNamespace"

class FakeApplePaySession {
onvalidatemerchant: (event: { validationURL: string }) => void
completeMerchantValidation = vi.fn()
abort = vi.fn()
begin = vi.fn()
}

type Respond = (onSuccess: Function, onError: Function) => void

const merchantValidated: Respond = onSuccess =>
onSuccess({ success: true, session_payload: { id: "merchant_session" } })
const merchantRejected: Respond = onSuccess =>
onSuccess({ success: false, error_code: "bad.domain", message: "Bad domain" })
const requestFailed: Respond = (_onSuccess, onError) =>
onError({}, {}, "processout-js.network-issue")

function setup(respond: Respond, configure: (applePay: any, calls: string[]) => void = () => {}) {
const ProcessOut = loadApplePay(FakeApplePaySession)
const instance = {
endpoint: () => "https://api.test/applepay/sessions",
apiRequest: (
_method: string,
_url: string,
_body: unknown,
onSuccess: Function,
onError: Function,
) => respond(onSuccess, onError),
}
const calls: string[] = []
const newSessionOnSuccess = vi.fn()
const newSessionOnError = vi.fn()
const onTokenizeError = vi.fn(() => calls.push("tokenize_error"))

const applePay = new ProcessOut.ApplePayWrapper(instance).newSession(
{ total: { label: "Order", amount: "10.00" } },
newSessionOnSuccess,
newSessionOnError,
)
configure(applePay, calls)
applePay.tokenize({}, vi.fn(), onTokenizeError)

const session: FakeApplePaySession = applePay.getSession()
const validateMerchant = () =>
session.onvalidatemerchant({ validationURL: "https://apple.test/validate" })

return {
calls,
session,
validateMerchant,
newSessionOnSuccess,
newSessionOnError,
onTokenizeError,
}
}

describe("existing behaviour (no session handlers set)", () => {
it("completes merchant validation without calling any handler", () => {
const { calls, session, validateMerchant } = setup(merchantValidated)

validateMerchant()

expect(session.completeMerchantValidation).toHaveBeenCalledWith({ id: "merchant_session" })
expect(session.abort).not.toHaveBeenCalled()
expect(calls).toEqual([])
})

it.each([
["the API rejects the merchant", merchantRejected, "bad.domain"],
["the request fails", requestFailed, "processout-js.network-issue"],
])("reports to the tokenize error handler and aborts when %s", (_name, respond, code) => {
const { session, validateMerchant, onTokenizeError } = setup(respond)

validateMerchant()

expect(onTokenizeError).toHaveBeenCalledOnce()
expect(onTokenizeError.mock.calls[0][0].code).toBe(code)
expect(session.completeMerchantValidation).not.toHaveBeenCalled()
expect(session.abort).toHaveBeenCalledOnce()
})

it.each([
["succeeds", merchantValidated],
["fails", merchantRejected],
])("never calls the callbacks given to newSession when validation %s", (_name, respond) => {
const { validateMerchant, newSessionOnSuccess, newSessionOnError } = setup(respond)

validateMerchant()

expect(newSessionOnSuccess).not.toHaveBeenCalled()
expect(newSessionOnError).not.toHaveBeenCalled()
})
})

describe("session handlers", () => {
it("calls onmerchantvalidated after the merchant validation is completed", () => {
const { calls, session, validateMerchant } = setup(merchantValidated, (applePay, calls) => {
applePay.onmerchantvalidated = () => {
expect(session.completeMerchantValidation).toHaveBeenCalledOnce()
calls.push("merchant_validated")
}
})

validateMerchant()

expect(calls).toEqual(["merchant_validated"])
})

it.each([
["the API rejects the merchant", merchantRejected, "bad.domain"],
["the request fails", requestFailed, "processout-js.network-issue"],
])(
"calls onmerchantvalidationerror before the tokenize error handler when %s",
(_name, respond, code) => {
const onMerchantValidationError = vi.fn()
const { calls, session, validateMerchant } = setup(respond, (applePay, calls) => {
applePay.onmerchantvalidationerror = (err: unknown) => {
onMerchantValidationError(err)
calls.push("merchant_validation_error")
}
})

validateMerchant()

expect(calls).toEqual(["merchant_validation_error", "tokenize_error"])
expect(onMerchantValidationError.mock.calls[0][0].code).toBe(code)
expect(session.abort).toHaveBeenCalledOnce()
},
)

it("still reports the error and aborts when onmerchantvalidationerror throws", () => {
const { session, validateMerchant, onTokenizeError } = setup(merchantRejected, applePay => {
applePay.onmerchantvalidationerror = () => {
throw new Error("listener blew up")
}
})

expect(validateMerchant).toThrow("listener blew up")
expect(onTokenizeError).toHaveBeenCalledOnce()
expect(session.abort).toHaveBeenCalledOnce()
})
})
Loading
Loading