Skip to content

Commit 89b6b96

Browse files
fix: prefill APM fields by canonical key and parse E.164 phone numbers
Prefilling an APM form via `initialData` had two problems. Values were matched only against the payment method's own parameter key, so the documented `initialData.phone_number` silently did nothing whenever a payment method named the field something else (for example `customerPhone`), leaving merchants to discover each one's internal parameter names. `email` and `phone_number` now also match on the parameter's type, so the canonical keys prefill on every payment method. An exact parameter-key match still wins, so a specific field can be targeted or a canonical value overridden. A phone number passed as an E.164 string was dropped into the national number input whole, with the dialing code set to the first entry in the available list. That selected the wrong country, left `+CC` inside the number box and submitted a malformed value. The string is now split on a longest-prefix match against the available dialing codes, falling back to the browser-locale default when the country isn't offered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ca78ac8 commit 89b6b96

4 files changed

Lines changed: 283 additions & 13 deletions

File tree

src/apm/types.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,25 @@ module ProcessOut {
3737

3838
export type Container = string | Element
3939

40+
/**
41+
* Values used to prefill the payment method's form fields.
42+
*
43+
* `email` and `phone_number` are canonical: they match the field by type, so
44+
* they work whatever the gateway names its own parameter. Any gateway
45+
* parameter key can also be passed directly, and takes precedence over the
46+
* canonical key for that type.
47+
*/
4048
export interface InitialData {
4149
email: string,
42-
phone_number: {
43-
dialing_code: string,
44-
value: string,
45-
}
50+
/**
51+
* Either an E.164 string ("+48123123123") or the split form, with the
52+
* country given as a dialing code ("+48").
53+
*/
54+
phone_number: string | {
55+
dialing_code?: string,
56+
value?: string,
57+
},
58+
[key: string]: unknown,
4659
}
4760
}
4861

src/apm/utils.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,107 @@ module ProcessOut {
4646
return (match || dialing_codes[0]).value;
4747
}
4848

49+
/**
50+
* Canonical `initialData` keys and the parameter type each one prefills.
51+
*
52+
* Payment methods name their own parameters, so a phone field can arrive as
53+
* `customerPhone` rather than `phone_number`. Matching on type as well as on
54+
* the raw key lets the documented canonical keys prefill on every payment
55+
* method, without the merchant having to know each one's parameter names.
56+
*/
57+
const CANONICAL_PREFILL_KEYS: Array<{ key: string, type: string }> = [
58+
{ key: 'email', type: 'email' },
59+
{ key: 'phone_number', type: 'phone' },
60+
]
61+
62+
/**
63+
* Find the value in `initialData` that should prefill the given parameter.
64+
* An exact key match wins, so a merchant can always target one specific
65+
* gateway parameter (or override a canonical key); otherwise we fall back to
66+
* the canonical key for the parameter's type.
67+
*/
68+
export function resolvePrefilledValue(
69+
initialData: object | undefined,
70+
param: { key: string, type: string },
71+
): unknown {
72+
if (!initialData) {
73+
return undefined;
74+
}
75+
76+
const data = initialData as Record<string, unknown>;
77+
78+
if (data[param.key] !== undefined && data[param.key] !== null) {
79+
return data[param.key];
80+
}
81+
82+
for (let i = 0; i < CANONICAL_PREFILL_KEYS.length; i++) {
83+
const canonical = CANONICAL_PREFILL_KEYS[i];
84+
if (canonical.type === param.type && data[canonical.key]) {
85+
return data[canonical.key];
86+
}
87+
}
88+
89+
return undefined;
90+
}
91+
92+
/**
93+
* Coerce a prefilled phone value into the `{ dialing_code, value }` shape the
94+
* phone field renders.
95+
*
96+
* Accepts the object form and a bare E.164 string (`"+48123123123"`). The
97+
* string is split on a longest-prefix match against the gateway's own dialing
98+
* codes so the right country is selected and only the national number lands
99+
* in the input — otherwise the whole string ends up in the number box and the
100+
* submitted value is malformed.
101+
*/
102+
export function normalizePhoneValue(
103+
value: unknown,
104+
dialing_codes: Array<{ region_code: string, value: string }>,
105+
): { dialing_code: string, value: string } {
106+
const defaultDialingCode = getDefaultDialingCode(dialing_codes);
107+
108+
if (isPlainObject(value)) {
109+
// `number` is the key the phone field emits on input, so accept it too:
110+
// a value read back off a `field-change` event can be fed straight in.
111+
const object = value as { dialing_code?: string, value?: string, number?: string };
112+
return {
113+
dialing_code: object.dialing_code || defaultDialingCode,
114+
value: digitsOnly(object.value || object.number || ''),
115+
};
116+
}
117+
118+
if (typeof value !== 'string') {
119+
return { dialing_code: defaultDialingCode, value: '' };
120+
}
121+
122+
// Strip separators the docs allow around an E.164 number ("+48 123 123 123").
123+
const compact = value.replace(/[^\d+]/g, '');
124+
125+
if (compact.charAt(0) !== '+') {
126+
return { dialing_code: defaultDialingCode, value: digitsOnly(compact) };
127+
}
128+
129+
// Longest prefix first, so "+1" doesn't win over "+1242".
130+
const matches = (dialing_codes || [])
131+
.filter(code => code.value && compact.indexOf(code.value) === 0)
132+
.sort((a, b) => b.value.length - a.value.length);
133+
134+
if (matches.length === 0) {
135+
// The gateway doesn't offer this country. Keep the digits so the merchant
136+
// sees what was passed rather than silently dropping it.
137+
return { dialing_code: defaultDialingCode, value: digitsOnly(compact) };
138+
}
139+
140+
return {
141+
dialing_code: matches[0].value,
142+
value: digitsOnly(compact.substring(matches[0].value.length)),
143+
};
144+
}
145+
146+
function digitsOnly(value: string): string {
147+
return value.replace(/\D/g, '');
148+
}
149+
49150
/**
50151
* Simple hash function for content comparison (djb2 algorithm)
51152
* @param str - String to hash

src/apm/views/NextSteps.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,16 @@ module ProcessOut {
3737

3838
state.values = forms.reduce((acc, form) => {
3939
form.parameters.parameter_definitions.forEach(param => {
40-
// Check for prefilled data from initialData
41-
const initialData = ContextImpl.context.initialData;
42-
const prefilledValue = initialData && initialData[param.key];
40+
// Check for prefilled data from initialData, by gateway parameter key or
41+
// by canonical key for the parameter type (email, phone_number).
42+
const prefilledValue = resolvePrefilledValue(ContextImpl.context.initialData, param);
4343

4444
// If we have prefilled data, use it and exit early
4545
if (prefilledValue) {
46-
// Special handling for phone numbers - convert string to expected object format
47-
if (param.type === 'phone' && typeof prefilledValue === 'string') {
48-
acc[param.key] = {
49-
dialing_code: param.dialing_codes[0].value,
50-
value: prefilledValue,
51-
};
46+
// Phone accepts an E.164 string or an object; both need splitting into
47+
// the { dialing_code, value } shape the field renders.
48+
if (param.type === 'phone') {
49+
acc[param.key] = normalizePhoneValue(prefilledValue, param.dialing_codes);
5250
} else {
5351
acc[param.key] = prefilledValue;
5452
}

test/apm/prefill.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { describe, expect, it } from "vitest"
2+
import { loadApmUtils, FakeNavigator } from "../support/loadNamespace"
3+
4+
type DialingCode = { region_code: string; value: string }
5+
6+
const CODES: DialingCode[] = [
7+
{ region_code: "PL", value: "+48" },
8+
{ region_code: "GB", value: "+44" },
9+
{ region_code: "US", value: "+1" },
10+
{ region_code: "BS", value: "+1242" },
11+
]
12+
13+
function normalizePhoneValue(
14+
value: unknown,
15+
dialingCodes: DialingCode[] = CODES,
16+
navigator: FakeNavigator = { language: "en-GB" },
17+
): { dialing_code: string; value: string } {
18+
return loadApmUtils(navigator).normalizePhoneValue(value, dialingCodes)
19+
}
20+
21+
function resolvePrefilledValue(
22+
initialData: object | undefined,
23+
param: { key: string; type: string },
24+
): unknown {
25+
return loadApmUtils({}).resolvePrefilledValue(initialData, param)
26+
}
27+
28+
describe("normalizePhoneValue", () => {
29+
it("splits a bare E.164 string into dialing code and national number", () => {
30+
expect(normalizePhoneValue("+48123123123")).toEqual({
31+
dialing_code: "+48",
32+
value: "123123123",
33+
})
34+
})
35+
36+
it("ignores separators in an E.164 string", () => {
37+
expect(normalizePhoneValue("+44 7700 900123")).toEqual({
38+
dialing_code: "+44",
39+
value: "7700900123",
40+
})
41+
expect(normalizePhoneValue("+44 (7700) 900-123")).toEqual({
42+
dialing_code: "+44",
43+
value: "7700900123",
44+
})
45+
})
46+
47+
it("prefers the longest matching dialing code", () => {
48+
expect(normalizePhoneValue("+1242570000")).toEqual({
49+
dialing_code: "+1242",
50+
value: "570000",
51+
})
52+
expect(normalizePhoneValue("+12025550123")).toEqual({
53+
dialing_code: "+1",
54+
value: "2025550123",
55+
})
56+
})
57+
58+
it("falls back to the locale default when the gateway has no matching code", () => {
59+
expect(normalizePhoneValue("+33612345678")).toEqual({
60+
dialing_code: "+44",
61+
value: "33612345678",
62+
})
63+
})
64+
65+
it("uses the locale default for a national-format string", () => {
66+
expect(normalizePhoneValue("07700900123")).toEqual({
67+
dialing_code: "+44",
68+
value: "07700900123",
69+
})
70+
})
71+
72+
it("passes through the object form", () => {
73+
expect(
74+
normalizePhoneValue({ dialing_code: "+48", value: "123123123" }),
75+
).toEqual({ dialing_code: "+48", value: "123123123" })
76+
})
77+
78+
it("accepts the `number` key the phone field emits on input", () => {
79+
expect(
80+
normalizePhoneValue({ dialing_code: "+48", number: "123123123" }),
81+
).toEqual({ dialing_code: "+48", value: "123123123" })
82+
})
83+
84+
it("fills in the locale default when the object omits the dialing code", () => {
85+
expect(normalizePhoneValue({ value: "7700900123" })).toEqual({
86+
dialing_code: "+44",
87+
value: "7700900123",
88+
})
89+
})
90+
91+
it("returns an empty number for a non-string, non-object value", () => {
92+
expect(normalizePhoneValue(undefined)).toEqual({
93+
dialing_code: "+44",
94+
value: "",
95+
})
96+
expect(normalizePhoneValue(42)).toEqual({ dialing_code: "+44", value: "" })
97+
})
98+
})
99+
100+
describe("resolvePrefilledValue", () => {
101+
it("matches the gateway parameter key exactly", () => {
102+
expect(
103+
resolvePrefilledValue(
104+
{ customerPhone: "+48123123123" },
105+
{ key: "customerPhone", type: "phone" },
106+
),
107+
).toBe("+48123123123")
108+
})
109+
110+
it("matches the canonical key by parameter type", () => {
111+
expect(
112+
resolvePrefilledValue(
113+
{ phone_number: "+48123123123" },
114+
{ key: "customerPhone", type: "phone" },
115+
),
116+
).toBe("+48123123123")
117+
118+
expect(
119+
resolvePrefilledValue(
120+
{ email: "a@b.com" },
121+
{ key: "customerEmail", type: "email" },
122+
),
123+
).toBe("a@b.com")
124+
})
125+
126+
it("prefers an exact key match over the canonical key", () => {
127+
expect(
128+
resolvePrefilledValue(
129+
{ phone_number: "+48123123123", customerPhone: "+441234567890" },
130+
{ key: "customerPhone", type: "phone" },
131+
),
132+
).toBe("+441234567890")
133+
})
134+
135+
it("does not apply a canonical key to an unrelated parameter type", () => {
136+
expect(
137+
resolvePrefilledValue(
138+
{ phone_number: "+48123123123" },
139+
{ key: "documentNumber", type: "text" },
140+
),
141+
).toBeUndefined()
142+
})
143+
144+
it("returns undefined when there is nothing to prefill", () => {
145+
expect(
146+
resolvePrefilledValue({}, { key: "customerPhone", type: "phone" }),
147+
).toBeUndefined()
148+
expect(
149+
resolvePrefilledValue(undefined, { key: "customerPhone", type: "phone" }),
150+
).toBeUndefined()
151+
})
152+
153+
it("keeps falsy-but-present exact values distinguishable from absent ones", () => {
154+
expect(
155+
resolvePrefilledValue({ agreed: false }, { key: "agreed", type: "boolean" }),
156+
).toBe(false)
157+
})
158+
})

0 commit comments

Comments
 (0)