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
3 changes: 3 additions & 0 deletions .github/workflows/build-lint-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,8 @@ jobs:
- name: Lint
run: yarn lint

- name: Test
run: yarn test

- name: Build
run: yarn build
5 changes: 5 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ export default [
{ files: ["**/*.{js,mjs,cjs,ts}"] },
{ files: ["**/*.js"], languageOptions: { sourceType: "script" } },
{ languageOptions: { globals: globals.browser } },
// Test files and Vitest config run in Node, not the browser.
{
files: ["test/**/*.ts", "vitest.config.ts"],
languageOptions: { globals: { ...globals.node } },
},
{ ignores: ["dist/*", "node_modules/*", "src/polyfills/*"] },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
Expand Down
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
{
"name": "processout.js",
"version": "1.9.8",
"version": "1.9.9",
"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",
"build:modal": "tsc -p src/modal && uglifyjs --compress --keep-fnames --ie8 dist/modal.js -o dist/modal.js",
"build:test": "yarn build:processout & yarn build:modal && yarn append-debug-mode && yarn append-version",
"build": "yarn build:processout & yarn build:modal && yarn append-version",
"verify": "yarn lint & yarn build",
"test": "vitest run",
"test:watch": "vitest",
"verify": "yarn lint & yarn build && yarn test",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"format": "prettier --ignore-path .gitignore --write \"**/*.+(ts|json)\"",
Expand Down Expand Up @@ -35,7 +37,8 @@
"typescript": "^5.7.3",
"typescript-eslint": "^8.23.0",
"uglify-js": "3.8.0",
"vite": "^6.1.0"
"vite": "^6.1.0",
"vitest": "^3"
},
"lint-staged": {
"*.{ts,json}": "eslint --cache --fix --no-warn-ignored"
Expand Down
21 changes: 15 additions & 6 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 },
}
Comment thread
roshan-gorasia-cko marked this conversation as resolved.

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 || dialing_codes[0] && dialing_codes[0].value || '',
number: value && value.value || '',
number: value && value.number || '',
iso: ''
Comment thread
roshan-gorasia-cko marked this conversation as resolved.
});

Expand Down Expand Up @@ -101,10 +101,10 @@ module ProcessOut {
dialingCodesRef.value = iso;
}

// Trigger callback to update form state if there's a value
if (value) {
oninput && oninput(name, state, true);
}
// Note: we intentionally do NOT emit oninput here. The form value is
// already seeded (normalised to { dialing_code, number }) by NextSteps,
// and loadScript invokes this callback on every render — emitting an
// initial update each time would re-render the form in a loop.

setState({
dialing_code: dialingCode,
Expand Down Expand Up @@ -243,6 +243,15 @@ module ProcessOut {

if (currentValue.length < getDialingCode(dialingCode).length) {
phoneNumber = cleanNumber;
// Propagate the (now empty/cleared) number to the form so validation
// reflects it — otherwise clearing the field back into the dialing-code
// prefix leaves a stale value in the form and required checks pass.
if (state.number !== phoneNumber) {
oninput && oninput(name, {
dialing_code: dialingCode,
number: phoneNumber,
});
}
dialingCodesRef.focus()
dialingCodesRef.showPicker()
setState({
Expand Down
10 changes: 9 additions & 1 deletion src/apm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,15 @@ module ProcessOut {
email: string,
phone_number: {
dialing_code: string,
value: string,
/**
* The phone number. `number` is the canonical key (matches what the API
* expects); `value` is a deprecated alias kept for backward
* compatibility. Both are optional — if neither is set the phone
* starts empty.
*/
number?: string,
/** @deprecated Use `number`. */
value?: string,
}
}
}
Expand Down
67 changes: 67 additions & 0 deletions src/apm/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,73 @@ module ProcessOut {
return proto === null || proto === Object.prototype;
}

/**
* Normalise a form field value to the scalar used for validation.
*
* Most fields are primitives, but the phone field is an object. Its canonical
* key is `number` (the shape the API reads and the Phone component emits); we
* also read a legacy `value` key so any older/prefilled shape still validates.
* Without this, validation (e.g. the required check) sees a non-empty object
* and silently passes even when the number has been cleared.
*/
export function getComparableFieldValue(value: unknown): unknown {
if (isPlainObject(value)) {
const record = value as Record<string, unknown>;
if ('number' in record) {
return record.number;
}
if ('value' in record) {
return record.value;
}
}
return value;
}

/**
* Coerce a phone field value into the canonical `{ dialing_code, number }`
* shape the API expects (api PhoneValue → json `dialing_code` / `number`).
*
* Accepts a bare string/number (national/E.164 number), or an object using
* either the canonical `number` key or the legacy/merchant-facing `value`
* key, and falls back to `defaultDialingCode` when no dialing code is
* supplied. Numbers are coerced to strings so a numeric prefill isn't
* silently dropped. Keeps `initialData.phone_number.value` working while
* sending the correct key.
*/
export function normalizePhoneValue(
value: unknown,
defaultDialingCode: string,
): { dialing_code: string; number: string } {
// Coerce a scalar phone part to a string; anything else -> undefined.
const toNumberString = (candidate: unknown): string | undefined => {
if (typeof candidate === "string") {
return candidate;
}
if (typeof candidate === "number" && isFinite(candidate)) {
return String(candidate);
}
return undefined;
};

const bare = toNumberString(value);
if (bare !== undefined) {
return { dialing_code: defaultDialingCode, number: bare };
}
if (isPlainObject(value)) {
const record = value as Record<string, unknown>;
const dialingCode =
typeof record.dialing_code === "string" && record.dialing_code
? record.dialing_code
: defaultDialingCode;
let number = toNumberString(record.number);
if (number === undefined) {
number = toNumberString(record.value);
}
return { dialing_code: dialingCode, number: number || "" };
}
return { dialing_code: defaultDialingCode, number: "" };
}

export const isEmpty = (value: Record<string, unknown> | Array<any>): boolean => {
if (Array.isArray(value)) {
return value.length === 0;
Expand Down
15 changes: 5 additions & 10 deletions src/apm/views/NextSteps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,10 @@ module ProcessOut {

// 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 values must be normalised to the canonical { dialing_code,
// number } shape (accepts a string or the legacy `value` key).
if (param.type === 'phone') {
acc[param.key] = normalizePhoneValue(prefilledValue, param.dialing_codes[0].value);
} else {
acc[param.key] = prefilledValue;
}
Expand All @@ -60,10 +58,7 @@ module ProcessOut {
acc[param.key] = param.available_values.find(item => item.preselected) && param.available_values.find(item => item.preselected).value || param.available_values[0] && param.available_values[0].value || ''
break;
case 'phone':
acc[param.key] = {
dialing_code: param.dialing_codes[0].value,
value: '',
}
acc[param.key] = normalizePhoneValue('', param.dialing_codes[0].value)
break;
default:
acc[param.key] = ''
Expand Down
9 changes: 2 additions & 7 deletions src/apm/views/utils/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,7 @@ module ProcessOut {
return
}

let actualValue;
if (isPlainObject(value) && 'value' in value) {
actualValue = value.value;
} else {
actualValue = value;
}
const actualValue = getComparableFieldValue(value);

switch (true) {
case validation.required &&
Expand Down Expand Up @@ -192,7 +187,7 @@ module ProcessOut {
onblur: onBlur(setState),
errored: !!error,
disabled: state.loading,
number: value as PhoneState,
value: value as PhoneState,
});
break;
}
Expand Down
79 changes: 79 additions & 0 deletions test/apm/phone-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest"
import { loadApmUtils } from "../support/loadNamespace"

const { getComparableFieldValue } = loadApmUtils()

/**
* Regression coverage for the phone "required" validation bug: the Phone
* component emits its value under `number`, while the initial/prefilled seed
* uses `value`. The validator normalises both via getComparableFieldValue, so
* an empty phone number is recognised as empty regardless of which shape is
* present (previously the `number` shape was treated as a non-empty object and
* the required check silently passed).
*/
describe("getComparableFieldValue", () => {
it("returns primitives unchanged", () => {
expect(getComparableFieldValue("hello")).toBe("hello")
expect(getComparableFieldValue("")).toBe("")
expect(getComparableFieldValue(0)).toBe(0)
expect(getComparableFieldValue(undefined)).toBe(undefined)
})

it("reads the number from the seed/prefill `value` shape", () => {
expect(
getComparableFieldValue({ dialing_code: "+44", value: "7911123456" }),
).toBe("7911123456")
expect(getComparableFieldValue({ dialing_code: "+44", value: "" })).toBe("")
})

it("reads the number from the emitted `number` shape", () => {
expect(
getComparableFieldValue({ dialing_code: "+44", number: "7911123456" }),
).toBe("7911123456")
})

it("returns an empty string for a cleared `number` (the reported bug)", () => {
// Shopper typed a number then removed it: the value is now { number: "" }.
// This must resolve to "" so the required check fires.
expect(getComparableFieldValue({ dialing_code: "+44", number: "" })).toBe("")
})

it("prefers the canonical `number` when both keys are present", () => {
expect(
getComparableFieldValue({ value: "from-value", number: "from-number" }),
).toBe("from-number")
})

it("returns the object unchanged when neither key is present", () => {
const obj = { dialing_code: "+44" }
expect(getComparableFieldValue(obj)).toBe(obj)
})
})

/**
* Mirrors form.ts validateField's required rule against the normalised value,
* documenting the end-to-end expectation the SDK relies on.
*/
function isMissingRequired(value: unknown): boolean {
const actualValue = getComparableFieldValue(value)
return (
typeof value === "undefined" ||
(typeof actualValue === "string" && actualValue.length === 0)
)
}

describe("required check on phone values", () => {
it("flags an empty phone number in either shape", () => {
expect(isMissingRequired({ dialing_code: "+44", value: "" })).toBe(true)
expect(isMissingRequired({ dialing_code: "+44", number: "" })).toBe(true)
})

it("accepts a provided phone number in either shape", () => {
expect(isMissingRequired({ dialing_code: "+44", value: "7911123456" })).toBe(
false,
)
expect(
isMissingRequired({ dialing_code: "+44", number: "7911123456" }),
).toBe(false)
})
})
Loading
Loading