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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
9 changes: 5 additions & 4 deletions src/apm/elements/phone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: ''
});

Expand Down Expand Up @@ -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({
Expand Down
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, 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
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
9 changes: 6 additions & 3 deletions src/apm/views/utils/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -192,7 +195,7 @@ module ProcessOut {
onblur: onBlur(setState),
errored: !!error,
disabled: state.loading,
number: value as PhoneState,
value: value as PhoneState,
});
break;
}
Expand Down
145 changes: 145 additions & 0 deletions test/apm/phone-prefill-wiring.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>, validation: Record<string, any> = {}) {
return {
loading: false,
form: { values, errors: {}, validation, touched: {} },
}
}

function validate(values: Record<string, any>) {
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<string, any>) {
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)
})
})
Loading
Loading