diff --git a/src/apm/types.ts b/src/apm/types.ts index e933d75..60bf123 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 55fd75b..02523bb 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, value }` shape the + * phone field renders. + * + * 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, value: string } { + const defaultDialingCode = getDefaultDialingCode(dialing_codes); + + if (isPlainObject(value)) { + // `number` is the key the phone field emits on input, so accept it too: + // a value read back off a `field-change` event can be fed straight in. + const object = value as { dialing_code?: string, value?: string, number?: string }; + return { + dialing_code: object.dialing_code || defaultDialingCode, + value: digitsOnly(object.value || object.number || ''), + }; + } + + if (typeof value !== 'string') { + return { dialing_code: defaultDialingCode, value: '' }; + } + + // 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, value: 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, value: digitsOnly(compact) }; + } + + return { + dialing_code: matches[0].value, + value: 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 aeb9205..5548c09 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/test/apm/prefill.test.ts b/test/apm/prefill.test.ts new file mode 100644 index 0000000..7df768a --- /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; value: 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", + value: "123123123", + }) + }) + + it("ignores separators in an E.164 string", () => { + expect(normalizePhoneValue("+44 7700 900123")).toEqual({ + dialing_code: "+44", + value: "7700900123", + }) + expect(normalizePhoneValue("+44 (7700) 900-123")).toEqual({ + dialing_code: "+44", + value: "7700900123", + }) + }) + + it("prefers the longest matching dialing code", () => { + expect(normalizePhoneValue("+1242570000")).toEqual({ + dialing_code: "+1242", + value: "570000", + }) + expect(normalizePhoneValue("+12025550123")).toEqual({ + dialing_code: "+1", + value: "2025550123", + }) + }) + + it("falls back to the locale default when the gateway has no matching code", () => { + expect(normalizePhoneValue("+33612345678")).toEqual({ + dialing_code: "+44", + value: "33612345678", + }) + }) + + it("uses the locale default for a national-format string", () => { + expect(normalizePhoneValue("07700900123")).toEqual({ + dialing_code: "+44", + value: "07700900123", + }) + }) + + it("passes through the object form", () => { + expect( + normalizePhoneValue({ dialing_code: "+48", value: "123123123" }), + ).toEqual({ dialing_code: "+48", value: "123123123" }) + }) + + it("accepts the `number` key the phone field emits on input", () => { + expect( + normalizePhoneValue({ dialing_code: "+48", number: "123123123" }), + ).toEqual({ dialing_code: "+48", value: "123123123" }) + }) + + it("fills in the locale default when the object omits the dialing code", () => { + expect(normalizePhoneValue({ value: "7700900123" })).toEqual({ + dialing_code: "+44", + value: "7700900123", + }) + }) + + it("returns an empty number for a non-string, non-object value", () => { + expect(normalizePhoneValue(undefined)).toEqual({ + dialing_code: "+44", + value: "", + }) + expect(normalizePhoneValue(42)).toEqual({ dialing_code: "+44", value: "" }) + }) +}) + +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) + }) +})