diff --git a/package.json b/package.json index faa35f1b..c27f4ba5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "processout.js", - "version": "1.9.11", + "version": "1.9.12", "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/apm/elements/phone.ts b/src/apm/elements/phone.ts index 996cbf81..620b1fc6 100644 --- a/src/apm/elements/phone.ts +++ b/src/apm/elements/phone.ts @@ -9,7 +9,7 @@ module ProcessOut { }> oninput?: FormFieldUpdate, onblur?: (key: string, value: { dialing_code: string, number: string }) => void, - value?: { dialing_code: string, value: string }, + value?: { dialing_code?: string, number?: string }, } const { div, label: labelEl, img, input, select, option } = elements @@ -61,7 +61,7 @@ module ProcessOut { // Use StateManager for internal state management const { state, setState } = useComponentState({ dialing_code: value && value.dialing_code || getDefaultDialingCode(dialing_codes), - number: value && value.value || '', + number: value && value.number || '', iso: '' }); @@ -101,9 +101,10 @@ module ProcessOut { dialingCodesRef.value = iso; } - // Trigger callback to update form state if there's a value + // Seed the form in the wire shape. `state` also carries `iso`, which is + // internal to the flag picker and isn't a gateway parameter. if (value) { - oninput && oninput(name, state, true); + oninput && oninput(name, { dialing_code: dialingCode, number: phoneNumber }, true); } setState({ diff --git a/src/apm/types.ts b/src/apm/types.ts index e933d75e..60bf123e 100644 --- a/src/apm/types.ts +++ b/src/apm/types.ts @@ -37,12 +37,25 @@ module ProcessOut { export type Container = string | Element + /** + * Values used to prefill the payment method's form fields. + * + * `email` and `phone_number` are canonical: they match the field by type, so + * they work whatever the gateway names its own parameter. Any gateway + * parameter key can also be passed directly, and takes precedence over the + * canonical key for that type. + */ export interface InitialData { email: string, - phone_number: { - dialing_code: string, - value: string, - } + /** + * Either an E.164 string ("+48123123123") or the split form, with the + * country given as a dialing code ("+48"). + */ + phone_number: string | { + dialing_code?: string, + value?: string, + }, + [key: string]: unknown, } } diff --git a/src/apm/utils.ts b/src/apm/utils.ts index 55fd75b1..301dab61 100644 --- a/src/apm/utils.ts +++ b/src/apm/utils.ts @@ -46,6 +46,107 @@ module ProcessOut { return (match || dialing_codes[0]).value; } + /** + * Canonical `initialData` keys and the parameter type each one prefills. + * + * Payment methods name their own parameters, so a phone field can arrive as + * `customerPhone` rather than `phone_number`. Matching on type as well as on + * the raw key lets the documented canonical keys prefill on every payment + * method, without the merchant having to know each one's parameter names. + */ + const CANONICAL_PREFILL_KEYS: Array<{ key: string, type: string }> = [ + { key: 'email', type: 'email' }, + { key: 'phone_number', type: 'phone' }, + ] + + /** + * Find the value in `initialData` that should prefill the given parameter. + * An exact key match wins, so a merchant can always target one specific + * gateway parameter (or override a canonical key); otherwise we fall back to + * the canonical key for the parameter's type. + */ + export function resolvePrefilledValue( + initialData: object | undefined, + param: { key: string, type: string }, + ): unknown { + if (!initialData) { + return undefined; + } + + const data = initialData as Record; + + if (data[param.key] !== undefined && data[param.key] !== null) { + return data[param.key]; + } + + for (let i = 0; i < CANONICAL_PREFILL_KEYS.length; i++) { + const canonical = CANONICAL_PREFILL_KEYS[i]; + if (canonical.type === param.type && data[canonical.key]) { + return data[canonical.key]; + } + } + + return undefined; + } + + /** + * Coerce a prefilled phone value into the `{ dialing_code, number }` shape the + * phone field renders and submits. + * + * Accepts the object form and a bare E.164 string (`"+48123123123"`). The + * string is split on a longest-prefix match against the gateway's own dialing + * codes so the right country is selected and only the national number lands + * in the input — otherwise the whole string ends up in the number box and the + * submitted value is malformed. + */ + export function normalizePhoneValue( + value: unknown, + dialing_codes: Array<{ region_code: string, value: string }>, + ): { dialing_code: string, number: string } { + const defaultDialingCode = getDefaultDialingCode(dialing_codes); + + if (isPlainObject(value)) { + // `value` is the key the prefill docs use, `number` the one the field emits + // on input, so a value read off a `field-change` event feeds straight back in. + const object = value as { dialing_code?: string, value?: string, number?: string }; + return { + dialing_code: object.dialing_code || defaultDialingCode, + number: digitsOnly(object.value || object.number || ''), + }; + } + + if (typeof value !== 'string') { + return { dialing_code: defaultDialingCode, number: '' }; + } + + // Strip separators the docs allow around an E.164 number ("+48 123 123 123"). + const compact = value.replace(/[^\d+]/g, ''); + + if (compact.charAt(0) !== '+') { + return { dialing_code: defaultDialingCode, number: digitsOnly(compact) }; + } + + // Longest prefix first, so "+1" doesn't win over "+1242". + const matches = (dialing_codes || []) + .filter(code => code.value && compact.indexOf(code.value) === 0) + .sort((a, b) => b.value.length - a.value.length); + + if (matches.length === 0) { + // The gateway doesn't offer this country. Keep the digits so the merchant + // sees what was passed rather than silently dropping it. + return { dialing_code: defaultDialingCode, number: digitsOnly(compact) }; + } + + return { + dialing_code: matches[0].value, + number: digitsOnly(compact.substring(matches[0].value.length)), + }; + } + + function digitsOnly(value: string): string { + return value.replace(/\D/g, ''); + } + /** * Simple hash function for content comparison (djb2 algorithm) * @param str - String to hash diff --git a/src/apm/views/NextSteps.ts b/src/apm/views/NextSteps.ts index aeb92055..5548c092 100644 --- a/src/apm/views/NextSteps.ts +++ b/src/apm/views/NextSteps.ts @@ -37,18 +37,16 @@ module ProcessOut { state.values = forms.reduce((acc, form) => { form.parameters.parameter_definitions.forEach(param => { - // Check for prefilled data from initialData - const initialData = ContextImpl.context.initialData; - const prefilledValue = initialData && initialData[param.key]; + // Check for prefilled data from initialData, by gateway parameter key or + // by canonical key for the parameter type (email, phone_number). + const prefilledValue = resolvePrefilledValue(ContextImpl.context.initialData, param); // If we have prefilled data, use it and exit early if (prefilledValue) { - // Special handling for phone numbers - convert string to expected object format - if (param.type === 'phone' && typeof prefilledValue === 'string') { - acc[param.key] = { - dialing_code: param.dialing_codes[0].value, - value: prefilledValue, - }; + // Phone accepts an E.164 string or an object; both need splitting into + // the { dialing_code, value } shape the field renders. + if (param.type === 'phone') { + acc[param.key] = normalizePhoneValue(prefilledValue, param.dialing_codes); } else { acc[param.key] = prefilledValue; } diff --git a/src/apm/views/utils/form.ts b/src/apm/views/utils/form.ts index ad2ddb38..868045c1 100644 --- a/src/apm/views/utils/form.ts +++ b/src/apm/views/utils/form.ts @@ -25,8 +25,11 @@ module ProcessOut { } let actualValue; - if (isPlainObject(value) && 'value' in value) { - actualValue = value.value; + if (isPlainObject(value)) { + // Phone holds { dialing_code, number } — validate the national number. + // `value` is the pre-#225 key, still accepted from merchant `initialData`. + const phone = value as { number?: string, value?: string }; + actualValue = phone.number !== undefined ? phone.number : phone.value; } else { actualValue = value; } @@ -192,7 +195,7 @@ module ProcessOut { onblur: onBlur(setState), errored: !!error, disabled: state.loading, - number: value as PhoneState, + value: value as PhoneState, }); break; } diff --git a/test/apm/phone-prefill-wiring.test.ts b/test/apm/phone-prefill-wiring.test.ts new file mode 100644 index 00000000..8659231c --- /dev/null +++ b/test/apm/phone-prefill-wiring.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest" +import { loadApmForm, loadApmPhone, VNodeStub } from "../support/loadNamespace" + +const CODES = [ + { region_code: "GB", value: "+44", name: "United Kingdom" }, + { region_code: "PL", value: "+48", name: "Poland" }, +] + +const PHONE_FIELD = { + type: "phone", + key: "customerPhone", + label: "Phone", + required: true, + dialing_codes: CODES, +} + +function formState(values: Record, validation: Record = {}) { + return { + loading: false, + form: { values, errors: {}, validation, touched: {} }, + } +} + +function validate(values: Record) { + const { validateForm } = loadApmForm() + let next: any + const ok = validateForm( + formState(values, { customerPhone: { required: true } }), + (fn: (prev: any) => any) => { next = fn(formState(values)) }, + ) + return { ok, errors: next.form.errors } +} + +function renderForm(values: Record) { + const { Form, phoneProps } = loadApmForm() + Form( + { parameters: { parameter_definitions: [PHONE_FIELD] } }, + formState(values), + () => undefined, + () => undefined, + ) + return phoneProps +} + +function findInput(node: VNodeStub | null): VNodeStub | undefined { + if (!node || typeof node !== "object") return undefined + if (node.tag === "input") return node + return (node.children || []) + .map(child => findInput(child as VNodeStub)) + .filter(Boolean)[0] +} + +describe("form -> Phone prop wiring", () => { + it("hands the stored phone state to the prop the field reads", () => { + const props = renderForm({ + customerPhone: { dialing_code: "+48", number: "123123123" }, + }) + + expect(props).toHaveLength(1) + expect(props[0].value).toEqual({ dialing_code: "+48", number: "123123123" }) + }) + + it("does not pass the phone state under any other prop name", () => { + const props = renderForm({ + customerPhone: { dialing_code: "+48", number: "123123123" }, + }) + + expect(props[0].number).toBeUndefined() + }) +}) + +describe("Phone field initialisation", () => { + it("renders the prefilled dialing code and national number", () => { + const { Phone } = loadApmPhone() + const input = findInput( + Phone({ + name: "customerPhone", + dialing_codes: CODES, + value: { dialing_code: "+48", number: "123123123" }, + }), + ) + + expect(input!.props.value).toBe("+48 123 123 123") + }) + + it("falls back to the default dialing code with no prefill", () => { + const { Phone } = loadApmPhone() + const input = findInput( + Phone({ name: "customerPhone", dialing_codes: CODES }), + ) + + expect(input!.props.value).toBe("+44 ") + }) + + it("seeds the form with the wire shape, without the internal iso field", () => { + const { Phone, emitted } = loadApmPhone() + Phone({ + name: "customerPhone", + dialing_codes: CODES, + value: { dialing_code: "+48", number: "123123123" }, + }) + + expect(emitted).toEqual([ + { + key: "customerPhone", + value: { dialing_code: "+48", number: "123123123" }, + isInitial: true, + }, + ]) + }) + + it("does not seed the form when there is nothing prefilled", () => { + const { Phone, emitted } = loadApmPhone() + Phone({ name: "customerPhone", dialing_codes: CODES }) + + expect(emitted).toEqual([]) + }) +}) + +describe("phone required validation", () => { + it("rejects a phone holding only a dialing code", () => { + const { ok, errors } = validate({ + customerPhone: { dialing_code: "+44", number: "" }, + }) + + expect(ok).toBe(false) + expect(errors.customerPhone).toBe("Missing required value") + }) + + it("accepts a phone with a national number", () => { + const { ok } = validate({ + customerPhone: { dialing_code: "+48", number: "123123123" }, + }) + + expect(ok).toBe(true) + }) + + it("still reads the pre-#225 `value` key", () => { + const { ok } = validate({ + customerPhone: { dialing_code: "+48", value: "123123123" }, + }) + + expect(ok).toBe(true) + }) +}) diff --git a/test/apm/prefill.test.ts b/test/apm/prefill.test.ts new file mode 100644 index 00000000..4e2deb48 --- /dev/null +++ b/test/apm/prefill.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest" +import { loadApmUtils, FakeNavigator } from "../support/loadNamespace" + +type DialingCode = { region_code: string; value: string } + +const CODES: DialingCode[] = [ + { region_code: "PL", value: "+48" }, + { region_code: "GB", value: "+44" }, + { region_code: "US", value: "+1" }, + { region_code: "BS", value: "+1242" }, +] + +function normalizePhoneValue( + value: unknown, + dialingCodes: DialingCode[] = CODES, + navigator: FakeNavigator = { language: "en-GB" }, +): { dialing_code: string; number: string } { + return loadApmUtils(navigator).normalizePhoneValue(value, dialingCodes) +} + +function resolvePrefilledValue( + initialData: object | undefined, + param: { key: string; type: string }, +): unknown { + return loadApmUtils({}).resolvePrefilledValue(initialData, param) +} + +describe("normalizePhoneValue", () => { + it("splits a bare E.164 string into dialing code and national number", () => { + expect(normalizePhoneValue("+48123123123")).toEqual({ + dialing_code: "+48", + number: "123123123", + }) + }) + + it("ignores separators in an E.164 string", () => { + expect(normalizePhoneValue("+44 7700 900123")).toEqual({ + dialing_code: "+44", + number: "7700900123", + }) + expect(normalizePhoneValue("+44 (7700) 900-123")).toEqual({ + dialing_code: "+44", + number: "7700900123", + }) + }) + + it("prefers the longest matching dialing code", () => { + expect(normalizePhoneValue("+1242570000")).toEqual({ + dialing_code: "+1242", + number: "570000", + }) + expect(normalizePhoneValue("+12025550123")).toEqual({ + dialing_code: "+1", + number: "2025550123", + }) + }) + + it("falls back to the locale default when the gateway has no matching code", () => { + expect(normalizePhoneValue("+33612345678")).toEqual({ + dialing_code: "+44", + number: "33612345678", + }) + }) + + it("uses the locale default for a national-format string", () => { + expect(normalizePhoneValue("07700900123")).toEqual({ + dialing_code: "+44", + number: "07700900123", + }) + }) + + it("accepts the documented `value` key in the object form", () => { + expect( + normalizePhoneValue({ dialing_code: "+48", value: "123123123" }), + ).toEqual({ dialing_code: "+48", number: "123123123" }) + }) + + it("accepts the `number` key the phone field emits on input", () => { + expect( + normalizePhoneValue({ dialing_code: "+48", number: "123123123" }), + ).toEqual({ dialing_code: "+48", number: "123123123" }) + }) + + it("fills in the locale default when the object omits the dialing code", () => { + expect(normalizePhoneValue({ value: "7700900123" })).toEqual({ + dialing_code: "+44", + number: "7700900123", + }) + }) + + it("returns an empty number for a non-string, non-object value", () => { + expect(normalizePhoneValue(undefined)).toEqual({ + dialing_code: "+44", + number: "", + }) + expect(normalizePhoneValue(42)).toEqual({ dialing_code: "+44", number: "" }) + }) +}) + +describe("resolvePrefilledValue", () => { + it("matches the gateway parameter key exactly", () => { + expect( + resolvePrefilledValue( + { customerPhone: "+48123123123" }, + { key: "customerPhone", type: "phone" }, + ), + ).toBe("+48123123123") + }) + + it("matches the canonical key by parameter type", () => { + expect( + resolvePrefilledValue( + { phone_number: "+48123123123" }, + { key: "customerPhone", type: "phone" }, + ), + ).toBe("+48123123123") + + expect( + resolvePrefilledValue( + { email: "a@b.com" }, + { key: "customerEmail", type: "email" }, + ), + ).toBe("a@b.com") + }) + + it("prefers an exact key match over the canonical key", () => { + expect( + resolvePrefilledValue( + { phone_number: "+48123123123", customerPhone: "+441234567890" }, + { key: "customerPhone", type: "phone" }, + ), + ).toBe("+441234567890") + }) + + it("does not apply a canonical key to an unrelated parameter type", () => { + expect( + resolvePrefilledValue( + { phone_number: "+48123123123" }, + { key: "documentNumber", type: "text" }, + ), + ).toBeUndefined() + }) + + it("returns undefined when there is nothing to prefill", () => { + expect( + resolvePrefilledValue({}, { key: "customerPhone", type: "phone" }), + ).toBeUndefined() + expect( + resolvePrefilledValue(undefined, { key: "customerPhone", type: "phone" }), + ).toBeUndefined() + }) + + it("keeps falsy-but-present exact values distinguishable from absent ones", () => { + expect( + resolvePrefilledValue({ agreed: false }, { key: "agreed", type: "boolean" }), + ).toBe(false) + }) +}) diff --git a/test/support/loadNamespace.ts b/test/support/loadNamespace.ts index e06ade4b..048a4ffd 100644 --- a/test/support/loadNamespace.ts +++ b/test/support/loadNamespace.ts @@ -127,3 +127,122 @@ export function loadDynamicCheckout(): DynamicCheckoutNamespace { return { namespace: moduleShim.exports, dispatchedEvents } } + +export interface VNodeStub { + tag: string + props: Record + children: any[] +} + +function elementsStub(): Record { + const tags = ["div", "label", "form", "input", "select", "option", "img", "span"] + const stub: Record = {} + tags.forEach(tag => { + stub[tag] = (props: any = {}, ...children: any[]): VNodeStub => ({ tag, props, children }) + }) + return stub +} + +export interface ApmFormHarness { + Form: (...args: any[]) => VNodeStub + validateForm: (state: any, setState: (fn: (prev: any) => any) => void) => boolean + phoneProps: Array> +} + +/** + * Load `src/apm/views/utils/form.ts` with its field components stubbed, so a + * test can assert which props a field type is actually handed. Guards the + * prop-name contract between the form and the components: `Props` carries an + * `[key: string]: any` index signature, so a misnamed prop typechecks and is + * silently swallowed into the element's attributes (see #277). + */ +export function loadApmForm(): ApmFormHarness { + const phoneProps: Array> = [] + const componentStub = () => ({ tag: "stub", props: {}, children: [] }) + + const scope: Record = { + elements: elementsStub(), + Phone: (props: Record) => { + phoneProps.push(props) + return componentStub() + }, + OTP: componentStub, + Select: componentStub, + Checkbox: componentStub, + Input: componentStub, + isPlainObject: (v: unknown) => + v !== null && typeof v === "object" && !Array.isArray(v), + isEmpty: (v: any) => Object.keys(v).length === 0, + createGroupedElements: (items: any[], _group: any, render: (item: any) => any) => + items.map(render), + ContextImpl: { context: { events: { emit: () => undefined } } }, + // Only reached on the validation-failure path, to scroll to the first error. + requestAnimationFrame: () => 0, + scrollTo: () => undefined, + } + + const names = Object.keys(scope) + const moduleShim = { exports: {} as Record } + const run = new Function( + "module", + "exports", + ...names, + `${compile("src/apm/views/utils/form.ts")}\nmodule.exports = ProcessOut;`, + ) + run(moduleShim, moduleShim.exports, ...names.map(n => scope[n])) + + return { + Form: moduleShim.exports.Form, + validateForm: moduleShim.exports.validateForm, + phoneProps, + } +} + +export interface ApmPhoneHarness { + Phone: (props: Record) => VNodeStub | null + emitted: Array<{ key: string; value: any; isInitial?: boolean }> +} + +/** + * Load `src/apm/elements/phone.ts` with a synchronous `loadScript` and a + * pass-through component state, and record what the field emits back to the + * form on initialisation. + */ +export function loadApmPhone(navigator: FakeNavigator = { language: "en-GB" }): ApmPhoneHarness { + const emitted: Array<{ key: string; value: any; isInitial?: boolean }> = [] + + const scope: Record = { + elements: elementsStub(), + navigator, + // No libphonenumber: the field falls back to its manual region lookup. + window: {}, + useComponentState: (initial: Record) => ({ + state: initial, + setState: () => undefined, + }), + getDefaultDialingCode: (codes: Array<{ value: string }>) => + (codes && codes[0] && codes[0].value) || "", + ContextImpl: { + context: { page: { loadScript: (_n: string, _u: string, cb: () => void) => cb() } }, + }, + } + + const names = Object.keys(scope) + const moduleShim = { exports: {} as Record } + const run = new Function( + "module", + "exports", + ...names, + `${compile("src/apm/elements/phone.ts")}\nmodule.exports = ProcessOut;`, + ) + run(moduleShim, moduleShim.exports, ...names.map(n => scope[n])) + + const Phone = (props: Record) => + moduleShim.exports.Phone({ + ...props, + oninput: (key: string, value: any, isInitial?: boolean) => + emitted.push({ key, value, isInitial }), + }) + + return { Phone, emitted } +}