diff --git a/package.json b/package.json index 2fb3318..a3bd89e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "processout.js", - "version": "1.9.16", + "version": "1.9.17", "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", diff --git a/src/dynamic-checkout/config/payment-config.ts b/src/dynamic-checkout/config/payment-config.ts index 6131ffe..6262f06 100644 --- a/src/dynamic-checkout/config/payment-config.ts +++ b/src/dynamic-checkout/config/payment-config.ts @@ -5,6 +5,16 @@ module ProcessOut { [gatewayName: string]: Record } + export type DynamicCheckoutPaymentMethodOverride = { + displayName?: string + logoUrl?: string + } + + // Keyed by "card" or by the APM gateway name, the same way as additionalData + interface DynamicCheckoutPaymentMethodsOverrides { + [paymentMethodKey: string]: DynamicCheckoutPaymentMethodOverride + } + export type DynamicCheckoutPublicConfigType = { invoiceId: string projectId: string @@ -19,6 +29,7 @@ module ProcessOut { additionalData?: DynamicCheckoutAdditionalDataByGateway cvcLabel?: string cvcPlaceholder?: string + paymentMethodsOverrides?: DynamicCheckoutPaymentMethodsOverrides } export type DynamicCheckoutInternalConfigType = { @@ -42,6 +53,7 @@ module ProcessOut { additionalData: DynamicCheckoutPublicConfigType["additionalData"] = {} cvcLabel: DynamicCheckoutPublicConfigType["cvcLabel"] = "" cvcPlaceholder: DynamicCheckoutPublicConfigType["cvcPlaceholder"] = "" + paymentMethodsOverrides: DynamicCheckoutPublicConfigType["paymentMethodsOverrides"] = {} invoiceDetails: DynamicCheckoutInternalConfigType["invoiceDetails"] constructor(config: DynamicCheckoutPublicConfigType) { @@ -63,6 +75,7 @@ module ProcessOut { payButtonText: this.payButtonText, cvcLabel: this.cvcLabel, cvcPlaceholder: this.cvcPlaceholder, + paymentMethodsOverrides: this.paymentMethodsOverrides, } } @@ -86,6 +99,37 @@ module ProcessOut { return this.additionalData[gatewayName] || {} } + // Merchants embed Dynamic Checkout with plain JS, so the overrides are untrusted input: + // anything malformed must degrade to "no override" instead of throwing and breaking checkout. + public getPaymentMethodDisplayOverride( + paymentMethodKey: string, + ): DynamicCheckoutPaymentMethodOverride { + const overrides = this.paymentMethodsOverrides + const isPlainObject = (value: unknown) => + typeof value === "object" && value !== null && !Array.isArray(value) + + if ( + !isPlainObject(overrides) || + !Object.prototype.hasOwnProperty.call(overrides, paymentMethodKey) + ) { + return {} + } + + const entry = overrides[paymentMethodKey] + + if (!isPlainObject(entry)) { + return {} + } + + const isNonEmptyString = (value: unknown): value is string => + typeof value === "string" && value.length > 0 + + return { + ...(isNonEmptyString(entry.displayName) && { displayName: entry.displayName }), + ...(isNonEmptyString(entry.logoUrl) && { logoUrl: entry.logoUrl }), + } + } + private setInitialConfig(config: DynamicCheckoutPublicConfigType) { if (!this.isValidConfig(config)) { throw new Error( @@ -105,6 +149,7 @@ module ProcessOut { this.additionalData = config.additionalData || {} this.cvcLabel = config.cvcLabel || "" this.cvcPlaceholder = config.cvcPlaceholder || "" + this.paymentMethodsOverrides = config.paymentMethodsOverrides || {} if (config.showStatusMessage !== undefined && config.showStatusMessage !== null) { this.showStatusMessage = config.showStatusMessage diff --git a/src/dynamic-checkout/payment-methods/apm.ts b/src/dynamic-checkout/payment-methods/apm.ts index 1c8bdaa..052c71d 100644 --- a/src/dynamic-checkout/payment-methods/apm.ts +++ b/src/dynamic-checkout/payment-methods/apm.ts @@ -17,6 +17,7 @@ module ProcessOut { private paymentConfig: DynamicCheckoutPaymentConfig private paymentMethod: PaymentMethod private paymentMethodDisplayName: string + private displayOverride: DynamicCheckoutPaymentMethodOverride private theme: DynamicCheckoutThemeType private resetContainerHtml: () => HTMLElement @@ -27,10 +28,14 @@ module ProcessOut { theme: DynamicCheckoutThemeType, resetContainerHtml: () => HTMLElement, ) { + const displayOverride = paymentConfig.getPaymentMethodDisplayOverride( + paymentMethod.apm.gateway_name, + ) + super( processOutInstance, - paymentMethod.display.name, - paymentMethod.display.logo.dark_url.vector, + displayOverride.displayName || paymentMethod.display.name, + displayOverride.logoUrl || paymentMethod.display.logo.dark_url.vector, paymentMethod.display.name, ) @@ -38,6 +43,7 @@ module ProcessOut { this.paymentConfig = paymentConfig this.paymentMethod = paymentMethod this.paymentMethodDisplayName = paymentMethod.display.name + this.displayOverride = displayOverride this.theme = theme this.resetContainerHtml = resetContainerHtml @@ -49,9 +55,10 @@ module ProcessOut { const { clientSecret } = this.paymentConfig const canSavePaymentMethod = apm.saving_allowed + // gateway_name is the action type that selects the flow, so only the logo can be overridden const actionHandlerOptions = new ActionHandlerOptions( apm.gateway_name, - apm.gateway_logo.dark_url.raster, + this.displayOverride.logoUrl || apm.gateway_logo.dark_url.raster, ) const cardPaymentOptions = { @@ -511,7 +518,7 @@ module ProcessOut { `${Translations.getText( "continue-with-apm-button", this.paymentConfig.locale, - )} ${this.paymentMethod.display.name}`, + )} ${this.displayOverride.displayName || this.paymentMethod.display.name}`, }, ]) diff --git a/src/dynamic-checkout/payment-methods/card.ts b/src/dynamic-checkout/payment-methods/card.ts index 38ea97d..1c0bf42 100644 --- a/src/dynamic-checkout/payment-methods/card.ts +++ b/src/dynamic-checkout/payment-methods/card.ts @@ -18,6 +18,7 @@ module ProcessOut { resetContainerHtml: () => HTMLElement, ) { const { display } = paymentMethod + const displayOverride = paymentConfig.getPaymentMethodDisplayOverride("card") const rightContent = HTMLElements.createElement({ tagName: "div", @@ -26,8 +27,8 @@ module ProcessOut { super( processOutInstance, - Translations.getText("card-label", paymentConfig.locale), - display.logo.light_url.vector, + displayOverride.displayName || Translations.getText("card-label", paymentConfig.locale), + displayOverride.logoUrl || display.logo.light_url.vector, display.name, rightContent, ) diff --git a/src/dynamic-checkout/payment-methods/native-apm.ts b/src/dynamic-checkout/payment-methods/native-apm.ts index 6254010..084e03b 100644 --- a/src/dynamic-checkout/payment-methods/native-apm.ts +++ b/src/dynamic-checkout/payment-methods/native-apm.ts @@ -25,8 +25,14 @@ module ProcessOut { ) { const { display, apm } = paymentMethod const { invoiceId, invoiceDetails } = paymentConfig - - super(processOutInstance, display.name, display.logo.dark_url.vector, display.name) + const displayOverride = paymentConfig.getPaymentMethodDisplayOverride(apm.gateway_name) + + super( + processOutInstance, + displayOverride.displayName || display.name, + displayOverride.logoUrl || display.logo.dark_url.vector, + display.name, + ) this.paymentConfig = paymentConfig this.paymentMethodName = apm.gateway_name diff --git a/src/dynamic-checkout/payment-methods/saved-apm.ts b/src/dynamic-checkout/payment-methods/saved-apm.ts index efb0998..acbc01f 100644 --- a/src/dynamic-checkout/payment-methods/saved-apm.ts +++ b/src/dynamic-checkout/payment-methods/saved-apm.ts @@ -6,6 +6,7 @@ module ProcessOut { private paymentConfig: DynamicCheckoutPaymentConfig private paymentMethod: PaymentMethod private paymentMethodDisplayName: string + private displayOverride: DynamicCheckoutPaymentMethodOverride private resetContainerHtml: () => HTMLElement constructor( @@ -17,10 +18,14 @@ module ProcessOut { deleteMode?: boolean, handleDeletePaymentMethod?: () => void, ) { + const displayOverride = paymentConfig.getPaymentMethodDisplayOverride( + paymentMethod.apm_customer_token.gateway_name, + ) + super( processOutInstance, - paymentMethod.display.name, - paymentMethod.display.logo.dark_url.vector, + displayOverride.displayName || paymentMethod.display.name, + displayOverride.logoUrl || paymentMethod.display.logo.dark_url.vector, paymentMethod.apm_customer_token.customer_token_id, paymentMethod.display.description, deleteMode, @@ -33,6 +38,7 @@ module ProcessOut { this.paymentConfig = paymentConfig this.paymentMethod = paymentMethod this.paymentMethodDisplayName = paymentMethod.display.name + this.displayOverride = displayOverride this.resetContainerHtml = resetContainerHtml if (!deleteMode) { @@ -53,9 +59,10 @@ module ProcessOut { clientSecret, } + // gateway_name is the action type that selects the flow, so only the logo can be overridden const actionHandlerOptions = new ActionHandlerOptions( apm_customer_token.gateway_name, - apm_customer_token.gateway_logo.dark_url.raster, + this.displayOverride.logoUrl || apm_customer_token.gateway_logo.dark_url.raster, ) this.setLoading(this.paymentConfig.locale) diff --git a/test/dynamic-checkout/payment-method-overrides.test.ts b/test/dynamic-checkout/payment-method-overrides.test.ts new file mode 100644 index 0000000..5cb03f2 --- /dev/null +++ b/test/dynamic-checkout/payment-method-overrides.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { loadDynamicCheckout } from "../support/loadNamespace" + +let ProcessOut: Record + +beforeEach(() => { + ProcessOut = loadDynamicCheckout().namespace +}) + +function createPaymentConfig(paymentMethodsOverrides?: unknown) { + return new ProcessOut.DynamicCheckoutPaymentConfig({ + projectId: "proj_test", + invoiceId: "iv_test", + paymentMethodsOverrides, + }) +} + +describe("DynamicCheckoutPaymentConfig.paymentMethodsOverrides", () => { + it("defaults to an empty object", () => { + expect(createPaymentConfig().getConfig().paymentMethodsOverrides).toEqual({}) + }) + + it("is exposed through getConfig", () => { + const overrides = { card: { displayName: "Credit card" } } + + expect(createPaymentConfig(overrides).getConfig().paymentMethodsOverrides).toBe(overrides) + }) +}) + +describe("DynamicCheckoutPaymentConfig.getPaymentMethodDisplayOverride", () => { + const malformedRoots = [undefined, null, "card", 42, true, [{ displayName: "Card" }]] + + it.each(malformedRoots)("returns no override when the option is %j", overrides => { + expect(createPaymentConfig(overrides).getPaymentMethodDisplayOverride("card")).toEqual({}) + }) + + it("returns no override for an unknown key", () => { + const paymentConfig = createPaymentConfig({ card: { displayName: "Credit card" } }) + + expect(paymentConfig.getPaymentMethodDisplayOverride("adyen_ideal")).toEqual({}) + }) + + it.each(["constructor", "__proto__", "toString", "hasOwnProperty"])( + "ignores inherited object members for the %s key", + key => { + expect(createPaymentConfig({}).getPaymentMethodDisplayOverride(key)).toEqual({}) + }, + ) + + it.each([null, "Credit card", 42, ["Credit card"]])( + "returns no override when the entry is %j", + entry => { + const paymentConfig = createPaymentConfig({ card: entry }) + + expect(paymentConfig.getPaymentMethodDisplayOverride("card")).toEqual({}) + }, + ) + + it("drops empty and non-string fields", () => { + const paymentConfig = createPaymentConfig({ + card: { displayName: "", logoUrl: 42 }, + paypal: { displayName: null, logoUrl: { url: "https://cdn.test/pp.svg" } }, + }) + + expect(paymentConfig.getPaymentMethodDisplayOverride("card")).toEqual({}) + expect(paymentConfig.getPaymentMethodDisplayOverride("paypal")).toEqual({}) + }) + + it("returns only the fields that are set", () => { + const paymentConfig = createPaymentConfig({ + card: { displayName: "Credit card" }, + paypal: { logoUrl: "https://cdn.test/pp.svg" }, + }) + + expect(paymentConfig.getPaymentMethodDisplayOverride("card")).toEqual({ + displayName: "Credit card", + }) + expect(paymentConfig.getPaymentMethodDisplayOverride("paypal")).toEqual({ + logoUrl: "https://cdn.test/pp.svg", + }) + }) + + it("returns both fields and ignores unknown ones", () => { + const paymentConfig = createPaymentConfig({ + adyen_ideal: { displayName: "iDEAL", logoUrl: "https://cdn.test/ideal.svg", color: "red" }, + }) + + expect(paymentConfig.getPaymentMethodDisplayOverride("adyen_ideal")).toEqual({ + displayName: "iDEAL", + logoUrl: "https://cdn.test/ideal.svg", + }) + }) + + it("never mutates the merchant's overrides", () => { + const overrides = { card: { displayName: "Credit card", logoUrl: "" } } + const snapshot = JSON.parse(JSON.stringify(overrides)) + + const result = createPaymentConfig(overrides).getPaymentMethodDisplayOverride("card") + result.displayName = "Changed" + + expect(overrides).toEqual(snapshot) + }) +})