Skip to content
Draft
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
21 changes: 17 additions & 4 deletions src/apm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
101 changes: 101 additions & 0 deletions src/apm/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

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
Expand Down
16 changes: 7 additions & 9 deletions src/apm/views/NextSteps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
158 changes: 158 additions & 0 deletions test/apm/prefill.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading