Skip to content
Merged
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
19 changes: 16 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
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.
* `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
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),
Expand All @@ -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',
}),
Expand Down
18 changes: 18 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@ export enum DeliverySpeed {
Standard = 'standard',
}

export enum PhoneVerificationMethod {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The PhoneVerificationMethod members have no doc comments.

The API docs define delivered_code as a code sent by SMS, voice call or messaging app, and network as mobile-operator or silent network authentication. A caller who reads only the TypeDoc cannot tell which to use, and can pick DeliveredCode or Other for silent network authentication. Suggest a one-line doc comment on each member.

🤖 Comment by Claude Opus 5.5.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in aebeb6e.

Reply by Claude Opus 5.5 on Greg's behalf.

/**
* 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',
}

export enum PaymentMethod {
BankDebit = 'bank_debit',
BankRedirect = 'bank_redirect',
Expand Down
17 changes: 17 additions & 0 deletions src/request/event.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/request/event.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EventParty, EventType } from '../constants.js';
import { assertValidDate } from '../utils.js';

interface EventProps {
/**
Expand Down Expand Up @@ -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) {
Expand Down
77 changes: 76 additions & 1 deletion src/request/location.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +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', () => {
Expand All @@ -20,4 +21,78 @@ 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).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');
});
});
60 changes: 58 additions & 2 deletions src/request/location.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { PhoneVerificationMethod } from '../constants.js';
import { ArgumentError } from '../errors.js';
import { assertValidDate } from '../utils.js';

export interface LocationProps {
/**
Expand Down Expand Up @@ -39,13 +41,29 @@ 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;
/**
* The most recent method used to verify the phone number.
*/
phoneVerificationMethod?: PhoneVerificationMethod;
Comment thread
oschwald marked this conversation as resolved.
Resolved

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: phoneVerificationMethod has no runtime check against the enum.

The type only helps TypeScript callers. A plain-JS caller can pass 'sms', or the enum key 'DeliveredCode' instead of the value 'delivered_code'. The constructor accepts it, and the server drops the input with only a response warning, which most integrations do not read.

TransactionReport already does this check for Tag with Object.values(Tag).includes(...), and the PHP, Python and Ruby clients reject invalid values. Suggest: throw ArgumentError when the value is set and !Object.values(PhoneVerificationMethod).includes(value).

🤖 Comment by Claude Opus 5.5.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 042133f.

Reply by Claude Opus 5.5 on Greg's behalf.

/**
* The date and time of the most recent verification of the phone number.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: No Date input in the request components is validated. Event.time (src/request/event.ts) has the same invalid-date-to-null problem, and this PR adds a second field with it.

Consider one helper in src/utils.ts, for example assertValidDate(value, name) that throws ArgumentError, and call it from both the Event and Location constructors. Then later time inputs do not repeat the silent drop.

🤖 Comment by Claude Opus 5.5.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 042133f. Location and Event both call assertValidDate from src/utils.ts, so an invalid Event.time now throws too. The CHANGELOG has an entry for it.

Reply by Claude Opus 5.5 on Greg's behalf.

* The time must not be before 1970-01-01T00:00:00Z or more than two days in
* the future. The web service ignores a time outside this range and returns
* a warning.
*/
phoneVerificationTime?: Date;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: An invalid Date is sent as null, and the server drops it with no warning.

Date.prototype.toJSON() returns null for an invalid date. Thus phoneVerificationTime: new Date('not a date') serializes as "phone_verification_time": null. The web service skips null inputs without a warning, so the caller never learns that the input was lost. A plain-JS caller can also pass a number (for example Date.now()), which goes out as a JSON number and is rejected by the server with only a warning.

The PHP, Python and Ruby clients reject a bad time. Suggest: in the Location constructor, throw ArgumentError when the value is set and is not a Date or getTime() is NaN.

🤖 Comment by Claude Opus 5.5.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 042133f. assertValidDate throws ArgumentError for a value that is not a valid Date.

Reply by Claude Opus 5.5 on Greg's behalf.

/**
* Whether the most recent verification of the phone number succeeded. Omit
* this if no verification was attempted.
*/
phoneWasVerificationSuccessful?: boolean;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: phoneWasVerificationSuccessful is not checked to be a boolean.

A plain-JS caller can pass a string from a form, query string or env var, for example 'false'. It goes out as the JSON string "false". The server rejects it with only a warning, so the verification result is lost. The Python and Ruby clients validate this input.

Suggest: throw ArgumentError when the value is not null/undefined and typeof value !== 'boolean'.

🤖 Comment by Claude Opus 5.5.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 042133f.

Reply by Claude Opus 5.5 on Greg's behalf.

}

const countryRegex = /^[A-Z]{2}$/;
Expand Down Expand Up @@ -76,6 +94,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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: Object.assign keeps the caller's Date by reference.

If the caller reuses the Date object, a later change to it changes the request:

const t = new Date();
const billing = new Billing({ phoneVerificationTime: t });
t.setTime(otherEventTime); // the request now sends otherEventTime

The new test in location.spec.ts asserts the shared reference with toBe(time). Suggest: copy the value in the constructor with new Date(location.phoneVerificationTime.getTime()), and assert with toEqual.

🤖 Comment by Claude Opus 5.5.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 042133f. The constructor copies the Date, and the test uses toEqual.

Reply by Claude Opus 5.5 on Greg's behalf.

/** @inheritDoc LocationProps.phoneWasVerificationSuccessful */
public phoneWasVerificationSuccessful?: boolean;

public constructor(location: LocationProps) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: The new fields do not follow the repo's CLAUDE.md conventions for request components.

CLAUDE.md says "Each component validates its inputs in the constructor and throws ArgumentError for invalid data." Under "Adding New Fields to Request Components" it also says "Add tests that verify validation and serialization". This constructor still checks only country. location.spec.ts and transaction.spec.ts test only valid input, with no invalid-input case for any of the three new fields.

🤖 Comment by Claude Opus 5.5.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 042133f. The constructor validates the three inputs, and each has an invalid-input test.

Reply by Claude Opus 5.5 on Greg's behalf.

if (location.country != null && !countryRegex.test(location.country)) {
Expand All @@ -84,6 +108,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()
);
}
}
}
23 changes: 23 additions & 0 deletions src/request/transaction.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -267,6 +268,28 @@ describe('Transaction()', () => {
expect(test.toString()).toContain('"billing":{"address_2":"foo"}');
});

it.each([
['billing', Billing],
['shipping', Shipping],
] as const)('it handles %s phone verification fields', (name, Cls) => {
const test = new Transaction({
[name]: new Cls({
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())[name]).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' });
Expand Down
17 changes: 17 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ArgumentError } from './errors.js';

/**
* Converts snake_case to camelCase
* @param {string} input - snake_case string
Expand Down Expand Up @@ -85,3 +87,18 @@ export function snakecaseKeys(input: unknown): unknown {
{} as Record<string, unknown>
);
}

/**
* 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`);
}
}
Loading