From 223f43c80351b3faafe5f2a93e88f17abba71f30 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 9 Oct 2026 20:44:09 +0000 Subject: [PATCH 1/4] Add phone verification inputs Add the optional phoneVerificationMethod, phoneVerificationTime, and phoneWasVerificationSuccessful properties to Location, which Billing and Shipping extend. They are sent as phone_verification_method, phone_verification_time, and phone_was_verification_successful in both /billing and /shipping. They describe the most recent verification of the phone number. The new PhoneVerificationMethod enum lists the valid methods. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 16 +++++++++++--- README.md | 6 +++++ src/constants.ts | 6 +++++ src/request/location.spec.ts | 16 ++++++++++++++ src/request/location.ts | 22 +++++++++++++++++++ src/request/transaction.spec.ts | 39 +++++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c4a5376..c784ba41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,19 @@ CHANGELOG ========= -9.1.1 ------------------- - +9.2.0 +------------------ + +* Added the inputs `/billing/phone_verification_method`, + `/billing/phone_was_verification_successful`, + `/billing/phone_verification_time`, + `/shipping/phone_verification_method`, + `/shipping/phone_was_verification_successful`, and + `/shipping/phone_verification_time`. They describe the most recent + verification of the billing or shipping phone number. You may provide them + by providing `phoneVerificationMethod`, `phoneWasVerificationSuccessful`, + and `phoneVerificationTime` to `Billing` or `Shipping`. The + `PhoneVerificationMethod` enum lists the valid methods. * Added `maxmind` as a direct dependency. The `IpAddressWebRecord` type extends `CityResponse` from that package, so it is part of this package's published type surface. It was previously resolved only as a transitive dependency of diff --git a/README.md b/README.md index e8f33e02..df35cfdf 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,9 @@ try { lastName: 'Bar', phoneCountryCode: '1', phoneNumber: '123-456-1234', + phoneVerificationMethod: minFraud.Constants.PhoneVerificationMethod.DeliveredCode, + phoneVerificationTime: new Date('2026-10-01T14:30:00Z'), + phoneWasVerificationSuccessful: true, postal: '12345', region: 'MA', }), @@ -231,6 +234,9 @@ try { lastName: 'Bar', phoneCountryCode: '1', phoneNumber: '123-456-0000', + phoneVerificationMethod: minFraud.Constants.PhoneVerificationMethod.DeliveredCode, + phoneVerificationTime: new Date('2026-10-01T14:30:00Z'), + phoneWasVerificationSuccessful: true, postal: '12345', region: 'MA', }), diff --git a/src/constants.ts b/src/constants.ts index 80f351a6..79032ad3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -25,6 +25,12 @@ export enum DeliverySpeed { Standard = 'standard', } +export enum PhoneVerificationMethod { + DeliveredCode = 'delivered_code', + Network = 'network', + Other = 'other', +} + export enum PaymentMethod { BankDebit = 'bank_debit', BankRedirect = 'bank_redirect', diff --git a/src/request/location.spec.ts b/src/request/location.spec.ts index cab7ea9c..50e0008d 100644 --- a/src/request/location.spec.ts +++ b/src/request/location.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { PhoneVerificationMethod } from '../constants.js'; import { ArgumentError } from '../errors.js'; import Location from './location.js'; @@ -20,4 +21,19 @@ describe('Location()', () => { }); }).not.toThrow(); }); + + it('stores the phone verification properties', () => { + const time = new Date('2026-10-01T14:30:00Z'); + const location = new Location({ + country: 'CA', + phoneVerificationMethod: PhoneVerificationMethod.Network, + phoneVerificationTime: time, + phoneWasVerificationSuccessful: false, + }); + + expect(location.country).toBe('CA'); + expect(location.phoneVerificationMethod).toBe('network'); + expect(location.phoneVerificationTime).toBe(time); + expect(location.phoneWasVerificationSuccessful).toBe(false); + }); }); diff --git a/src/request/location.ts b/src/request/location.ts index 4c9577fc..fa13343e 100644 --- a/src/request/location.ts +++ b/src/request/location.ts @@ -1,3 +1,4 @@ +import { PhoneVerificationMethod } from '../constants.js'; import { ArgumentError } from '../errors.js'; export interface LocationProps { @@ -46,6 +47,21 @@ export interface LocationProps { * The phone number, without the country code, associated with the address. */ phoneCountryCode?: string; + /** + * The most recent method used to verify the phone number. + */ + phoneVerificationMethod?: PhoneVerificationMethod; + /** + * The date and time of the most recent verification of the phone number. + * The time must not be before 1970-01-01T00:00:00Z or more than two days in + * the future. + */ + phoneVerificationTime?: Date; + /** + * Whether the most recent verification of the phone number succeeded. Omit + * this if no verification was attempted. + */ + phoneWasVerificationSuccessful?: boolean; } const countryRegex = /^[A-Z]{2}$/; @@ -76,6 +92,12 @@ export default class Location implements LocationProps { public phoneNumber?: string; /** @inheritDoc LocationProps.phoneCountryCode */ public phoneCountryCode?: string; + /** @inheritDoc LocationProps.phoneVerificationMethod */ + public phoneVerificationMethod?: PhoneVerificationMethod; + /** @inheritDoc LocationProps.phoneVerificationTime */ + public phoneVerificationTime?: Date; + /** @inheritDoc LocationProps.phoneWasVerificationSuccessful */ + public phoneWasVerificationSuccessful?: boolean; public constructor(location: LocationProps) { if (location.country != null && !countryRegex.test(location.country)) { diff --git a/src/request/transaction.spec.ts b/src/request/transaction.spec.ts index 6d94525f..45fbe9af 100644 --- a/src/request/transaction.spec.ts +++ b/src/request/transaction.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, test } from 'vitest'; +import { PhoneVerificationMethod } from '../constants.js'; import { ArgumentError } from '../errors.js'; import Account from './account.js'; import Billing from './billing.js'; @@ -267,6 +268,44 @@ describe('Transaction()', () => { expect(test.toString()).toContain('"billing":{"address_2":"foo"}'); }); + it('it handles billing phone verification fields', () => { + const test = new Transaction({ + billing: new Billing({ + phoneVerificationMethod: PhoneVerificationMethod.DeliveredCode, + phoneVerificationTime: new Date('2026-10-01T14:30:00Z'), + phoneWasVerificationSuccessful: false, + }), + device: new Device({ + ipAddress: '1.1.1.1', + }), + }); + + expect(JSON.parse(test.toString()).billing).toEqual({ + phone_verification_method: 'delivered_code', + phone_verification_time: '2026-10-01T14:30:00.000Z', + phone_was_verification_successful: false, + }); + }); + + it('it handles shipping phone verification fields', () => { + const test = new Transaction({ + shipping: new Shipping({ + phoneVerificationMethod: PhoneVerificationMethod.DeliveredCode, + phoneVerificationTime: new Date('2026-10-01T14:30:00Z'), + phoneWasVerificationSuccessful: false, + }), + device: new Device({ + ipAddress: '1.1.1.1', + }), + }); + + expect(JSON.parse(test.toString()).shipping).toEqual({ + phone_verification_method: 'delivered_code', + phone_verification_time: '2026-10-01T14:30:00.000Z', + phone_was_verification_successful: false, + }); + }); + it('does not mutate the caller’s input objects when serializing', () => { const billing = new Billing({ address2: 'foo' }); const shipping = new Shipping({ address2: 'bar' }); From 042133f880a8154f86a565bfd11688a9c8342719 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 19 Oct 2026 20:43:46 +0000 Subject: [PATCH 2/4] Validate phone verification inputs Location now throws an ArgumentError if phoneVerificationMethod is not a PhoneVerificationMethod value, if phoneWasVerificationSuccessful is not a boolean, or if phoneVerificationTime is not a valid Date. It copies phoneVerificationTime, so a later change to the caller's Date does not change the request. The new assertValidDate helper in utils.ts does the Date check. Event uses it for time too. Before, an invalid time was sent as null. The billing and shipping serialization tests are now one table test. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++ src/request/event.spec.ts | 17 +++++++++ src/request/event.ts | 5 +++ src/request/location.spec.ts | 63 +++++++++++++++++++++++++++++++-- src/request/location.ts | 33 +++++++++++++++++ src/request/transaction.spec.ts | 28 ++++----------- src/utils.ts | 17 +++++++++ 7 files changed, 142 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c784ba41..d402d461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ CHANGELOG by providing `phoneVerificationMethod`, `phoneWasVerificationSuccessful`, and `phoneVerificationTime` to `Billing` or `Shipping`. The `PhoneVerificationMethod` enum lists the valid methods. +* `Event` now throws an `ArgumentError` if `time` is not a valid `Date`. + Before, the client sent an invalid `Date` as `null` and did not report an + error. * Added `maxmind` as a direct dependency. The `IpAddressWebRecord` type extends `CityResponse` from that package, so it is part of this package's published type surface. It was previously resolved only as a transitive dependency of diff --git a/src/request/event.spec.ts b/src/request/event.spec.ts index f6e83670..9b1a5c57 100644 --- a/src/request/event.spec.ts +++ b/src/request/event.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import Event from './event.js'; import { EventParty, EventType } from '../constants.js'; +import { ArgumentError } from '../errors.js'; describe('Event()', () => { it('sets `time` to now by default', () => { @@ -17,6 +18,22 @@ describe('Event()', () => { expect(event.time).toEqual(mockDate); }); + it('keeps a valid `time`', () => { + const time = new Date('2026-10-01T14:30:00Z'); + const event = new Event({ time }); + + expect(event.time).toEqual(time); + }); + + it.each([ + ['an invalid Date', new Date('not a date')], + ['a string', '2026-10-01T14:30:00Z'], + ])('throws an ArgumentError if `time` is %s', (_, value) => { + const test = () => new Event({ time: value as unknown as Date }); + expect(test).toThrow(ArgumentError); + expect(test).toThrow('event.time'); + }); + it('accepts credit_application event type', () => { const event = new Event({ type: EventType.CreditApplication, diff --git a/src/request/event.ts b/src/request/event.ts index 734ff106..fc75648a 100644 --- a/src/request/event.ts +++ b/src/request/event.ts @@ -1,4 +1,5 @@ import { EventParty, EventType } from '../constants.js'; +import { assertValidDate } from '../utils.js'; interface EventProps { /** @@ -43,6 +44,10 @@ export default class Event implements EventProps { public type?: EventType; public constructor(minfraudEvent: EventProps) { + if (minfraudEvent.time != null) { + assertValidDate(minfraudEvent.time, 'event.time'); + } + Object.assign(this, minfraudEvent); if (!this.time) { diff --git a/src/request/location.spec.ts b/src/request/location.spec.ts index 50e0008d..5e14c453 100644 --- a/src/request/location.spec.ts +++ b/src/request/location.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { PhoneVerificationMethod } from '../constants.js'; import { ArgumentError } from '../errors.js'; -import Location from './location.js'; +import Location, { LocationProps } from './location.js'; describe('Location()', () => { it('throws an error if country is not valid', () => { @@ -33,7 +33,66 @@ describe('Location()', () => { expect(location.country).toBe('CA'); expect(location.phoneVerificationMethod).toBe('network'); - expect(location.phoneVerificationTime).toBe(time); + expect(location.phoneVerificationTime).toEqual(time); expect(location.phoneWasVerificationSuccessful).toBe(false); }); + + it('copies `phoneVerificationTime`', () => { + const time = new Date('2026-10-01T14:30:00Z'); + const location = new Location({ phoneVerificationTime: time }); + + time.setTime(0); + + expect(location.phoneVerificationTime).toEqual( + new Date('2026-10-01T14:30:00Z') + ); + }); + + it('accepts null phone verification properties', () => { + expect( + () => + new Location({ + phoneVerificationMethod: null, + phoneVerificationTime: null, + phoneWasVerificationSuccessful: null, + } as unknown as LocationProps) + ).not.toThrow(); + }); + + it.each(['sms', 'Network', ''])( + 'throws an ArgumentError for phoneVerificationMethod %j', + (value) => { + const test = () => + new Location({ + phoneVerificationMethod: value as PhoneVerificationMethod, + }); + expect(test).toThrow(ArgumentError); + expect(test).toThrow('phoneVerificationMethod'); + } + ); + + it.each(['true', 1, 0])( + 'throws an ArgumentError for phoneWasVerificationSuccessful %j', + (value) => { + const test = () => + new Location({ + phoneWasVerificationSuccessful: value as unknown as boolean, + }); + expect(test).toThrow(ArgumentError); + expect(test).toThrow('phoneWasVerificationSuccessful'); + } + ); + + it.each([ + ['an invalid Date', new Date('not a date')], + ['a string', '2026-10-01T14:30:00Z'], + ['a number', 1790000000000], + ])('throws an ArgumentError if phoneVerificationTime is %s', (_, value) => { + const test = () => + new Location({ + phoneVerificationTime: value as unknown as Date, + }); + expect(test).toThrow(ArgumentError); + expect(test).toThrow('phoneVerificationTime'); + }); }); diff --git a/src/request/location.ts b/src/request/location.ts index fa13343e..11110fd0 100644 --- a/src/request/location.ts +++ b/src/request/location.ts @@ -1,5 +1,6 @@ import { PhoneVerificationMethod } from '../constants.js'; import { ArgumentError } from '../errors.js'; +import { assertValidDate } from '../utils.js'; export interface LocationProps { /** @@ -106,6 +107,38 @@ export default class Location implements LocationProps { ); } + if ( + location.phoneVerificationMethod != null && + !Object.values(PhoneVerificationMethod).includes( + location.phoneVerificationMethod + ) + ) { + throw new ArgumentError( + `"${location.phoneVerificationMethod}" is an invalid value for "phoneVerificationMethod"` + ); + } + + if ( + location.phoneWasVerificationSuccessful != null && + typeof location.phoneWasVerificationSuccessful !== 'boolean' + ) { + throw new ArgumentError( + '`phoneWasVerificationSuccessful` must be a boolean' + ); + } + + if (location.phoneVerificationTime != null) { + assertValidDate(location.phoneVerificationTime, 'phoneVerificationTime'); + } + Object.assign(this, location); + + // Copy the Date so that a later change to the caller's object does not + // change the request. + if (location.phoneVerificationTime != null) { + this.phoneVerificationTime = new Date( + location.phoneVerificationTime.getTime() + ); + } } } diff --git a/src/request/transaction.spec.ts b/src/request/transaction.spec.ts index 45fbe9af..06aca509 100644 --- a/src/request/transaction.spec.ts +++ b/src/request/transaction.spec.ts @@ -268,28 +268,12 @@ describe('Transaction()', () => { expect(test.toString()).toContain('"billing":{"address_2":"foo"}'); }); - it('it handles billing phone verification fields', () => { + it.each([ + ['billing', Billing], + ['shipping', Shipping], + ] as const)('it handles %s phone verification fields', (name, Cls) => { const test = new Transaction({ - billing: new Billing({ - phoneVerificationMethod: PhoneVerificationMethod.DeliveredCode, - phoneVerificationTime: new Date('2026-10-01T14:30:00Z'), - phoneWasVerificationSuccessful: false, - }), - device: new Device({ - ipAddress: '1.1.1.1', - }), - }); - - expect(JSON.parse(test.toString()).billing).toEqual({ - phone_verification_method: 'delivered_code', - phone_verification_time: '2026-10-01T14:30:00.000Z', - phone_was_verification_successful: false, - }); - }); - - it('it handles shipping phone verification fields', () => { - const test = new Transaction({ - shipping: new Shipping({ + [name]: new Cls({ phoneVerificationMethod: PhoneVerificationMethod.DeliveredCode, phoneVerificationTime: new Date('2026-10-01T14:30:00Z'), phoneWasVerificationSuccessful: false, @@ -299,7 +283,7 @@ describe('Transaction()', () => { }), }); - expect(JSON.parse(test.toString()).shipping).toEqual({ + expect(JSON.parse(test.toString())[name]).toEqual({ phone_verification_method: 'delivered_code', phone_verification_time: '2026-10-01T14:30:00.000Z', phone_was_verification_successful: false, diff --git a/src/utils.ts b/src/utils.ts index 65f95c28..c7565e23 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,5 @@ +import { ArgumentError } from './errors.js'; + /** * Converts snake_case to camelCase * @param {string} input - snake_case string @@ -85,3 +87,18 @@ export function snakecaseKeys(input: unknown): unknown { {} as Record ); } + +/** + * Throws an `ArgumentError` if `value` is not a valid `Date`. + * + * @param value - the value to check + * @param name - the property name to use in the error message + */ +export function assertValidDate( + value: unknown, + name: string +): asserts value is Date { + if (!(value instanceof Date) || Number.isNaN(value.getTime())) { + throw new ArgumentError(`\`${name}\` must be a valid Date`); + } +} From aebeb6e643bd38a85f41ab692942dc7875fca305 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 19 Oct 2026 20:43:57 +0000 Subject: [PATCH 3/4] Document phone verification methods Add a doc comment to each PhoneVerificationMethod member. State in the phoneVerificationTime doc comment that the web service ignores a time outside the range and returns a warning. Co-Authored-By: Claude Opus 5.5 --- src/constants.ts | 12 ++++++++++++ src/request/location.ts | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/constants.ts b/src/constants.ts index 79032ad3..e61846c7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -26,8 +26,20 @@ export enum DeliverySpeed { } export enum PhoneVerificationMethod { + /** + * A one-time code delivered to the phone number by SMS, an automated voice + * call, or a messaging app, and entered by the user. + */ DeliveredCode = 'delivered_code', + /** + * Carrier or SIM-based silent network authentication. No code is sent to + * the device. + */ Network = 'network', + /** + * A method not listed above, such as flash or missed call verification, or + * a method you do not know because a third party performed the check. + */ Other = 'other', } diff --git a/src/request/location.ts b/src/request/location.ts index 11110fd0..2790e8a1 100644 --- a/src/request/location.ts +++ b/src/request/location.ts @@ -55,7 +55,8 @@ export interface LocationProps { /** * The date and time of the most recent verification of the phone number. * The time must not be before 1970-01-01T00:00:00Z or more than two days in - * the future. + * the future. The web service ignores a time outside this range and returns + * a warning. */ phoneVerificationTime?: Date; /** From 9cb1b7f897b36e8c384620d025446cc119461a5c Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 19 Oct 2026 20:44:03 +0000 Subject: [PATCH 4/4] Fix swapped phone number doc comments The doc comments for phoneNumber and phoneCountryCode in LocationProps described the other property. Co-Authored-By: Claude Opus 5.5 --- src/request/location.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/request/location.ts b/src/request/location.ts index 2790e8a1..d14db010 100644 --- a/src/request/location.ts +++ b/src/request/location.ts @@ -41,11 +41,11 @@ export interface LocationProps { */ postal?: string; /** - * The phone country code for the phone number associated with the address. + * The phone number, without the country code, associated with the address. */ phoneNumber?: string; /** - * The phone number, without the country code, associated with the address. + * The phone country code for the phone number associated with the address. */ phoneCountryCode?: string; /**