Skip to content
Open
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
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,48 @@
# Changelog

## v2.11.0

* Added the `phone_verification_method`,
`phone_was_verification_successful`, and `phone_verification_time`
attributes to `Minfraud::Components::Billing` and
`Minfraud::Components::Shipping`. They describe the most recent
verification of the billing or shipping phone number.
* Boolean inputs are now always sent as JSON booleans, and `false` is no
Comment thread
oschwald marked this conversation as resolved.
longer dropped. Previously, every input set to `false` was omitted.
Now only `nil` inputs are omitted. In particular:
* `true` for `/credit_card/was_3d_secure_successful` was sent as the
string `"true"`, which the web service rejected and dropped. It is
now sent as a JSON boolean, so it reaches the web service.
* Custom input values of `true` and `false` are now sent as JSON
booleans, and `false` is no longer dropped.
* **Warning:** If a custom input is a string type on your account, a
Ruby `true` or `false` for it is now sent as a JSON boolean. The web
service rejects a boolean for a string custom input with an
`INPUT_INVALID` warning. Pass a string, such as `'true'`, instead.
* With validation on, `true` or `false` for a non-boolean input now
raises `InvalidInputError`. This includes `token` on
`Minfraud::Components::CreditCard`, `tracking_token` on
`Minfraud::Components::Device`, and `chargeback_code`, `notes`, and
`transaction_id` on `Minfraud::Components::Report::Transaction`.
Previously, these accepted a boolean, and `transaction_id: false`
raised `NoMethodError`.
* `/order/has_gift_message`, `/order/is_gift`, and
`/payment/was_authorized` already sent `true` as a JSON boolean.
* The `*_values` class methods of enum attributes, such as
`Minfraud::Components::Event.type_values`, now exist only on the class
that declares the enum and its subclasses. Previously, every class
responded to them. `mapping` on a subclass now includes the enums of
its parent class.
* Enum setters, such as `Minfraud::Components::Event#type=`, now raise
`NotEnumValueError` for a value that is not a String, a Symbol, or
`nil`. Previously, `false` cleared the attribute, and `true` raised
`NoMethodError`. The `*_values` class methods now return a frozen
array.
* With validation on, `Minfraud::Components::Event` now rejects a `time`
that the web service rejects. Previously, the client accepted hour 24,
second 60, a UTC offset of 24 hours or more, and a lowercase `t` or
`z`.

## v2.10.0 (2026-07-21)

* Added the `residential` attribute to the `anonymizer` object on
Expand Down
52 changes: 29 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,31 +102,37 @@ assessment = Minfraud::Assessments.new(
domain: 'maxmind.com',
},
billing: {
first_name: 'First',
last_name: 'Last',
company: 'Company',
address: '101 Address Rd.',
address_2: 'Unit 5',
city: 'New Haven',
region: 'CT',
country: 'US',
postal: '06510',
phone_number: '123-456-7890',
phone_country_code: '1',
first_name: 'First',
last_name: 'Last',
company: 'Company',
address: '101 Address Rd.',
address_2: 'Unit 5',
city: 'New Haven',
region: 'CT',
country: 'US',
postal: '06510',
phone_number: '123-456-7890',
phone_country_code: '1',
phone_verification_method: :delivered_code,
phone_verification_time: '2026-10-01T14:30:00Z',
phone_was_verification_successful: true,
},
shipping: {
first_name: 'ShipFirst',
last_name: 'ShipLast',
company: 'ShipCo',
address: '322 Ship Addr. Ln.',
address_2: 'St. 43',
city: 'Nowhere',
region: 'OK',
country: 'US',
postal: '73003',
phone_number: '123-456-0000',
phone_country_code: '1',
delivery_speed: :same_day,
first_name: 'ShipFirst',
last_name: 'ShipLast',
company: 'ShipCo',
address: '322 Ship Addr. Ln.',
address_2: 'St. 43',
city: 'Nowhere',
region: 'OK',
country: 'US',
postal: '73003',
phone_number: '123-456-0000',
phone_country_code: '1',
delivery_speed: :same_day,
phone_verification_method: :delivered_code,
phone_verification_time: '2026-10-01T14:30:00Z',
phone_was_verification_successful: true,
},
payment: {
method: :card,
Expand Down
57 changes: 46 additions & 11 deletions lib/minfraud/components/addressable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ module Components
# This is a parent class for the Billing and Shipping components.
class Addressable < Base
include Minfraud::Validates
include ::Minfraud::Enum

# The first name of the end user as provided in their billing / shipping
# information.
Expand Down Expand Up @@ -74,20 +75,52 @@ class Addressable < Base
# @return [String, nil]
attr_accessor :phone_country_code

# The most recent method used to verify the phone number. This must be
# one of +:delivered_code+, +:network+, or +:other+.
# +:delivered_code+ is a code delivered to the phone, such as by SMS,
# voice call, or messaging app. +:network+ is verification through the
# mobile network operator, such as silent network authentication.
#
# @!attribute phone_verification_method
#
# @return [Symbol, nil]
enum_accessor :phone_verification_method, %i[delivered_code network other]

# Whether the most recent verification of the phone number
# succeeded. Do not include this field if no verification was
# attempted.
#
# @return [Boolean, nil]
attr_accessor :phone_was_verification_successful

# The date and time of the most recent verification of the phone
# number. The string must be in the RFC 3339 date-time format, e.g.,
# "2012-04-12T23:20:50.52Z". 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.
#
# @see https://datatracker.ietf.org/doc/html/rfc3339
#
# @return [String, nil]
attr_accessor :phone_verification_time

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 repo CLAUDE.md says "Validation happens in setters before assignment" and shows a def new_field=(value) setter that calls validate_*. The new fields use a plain attr_accessor, so billing.phone_verification_time = 'garbage' or billing.phone_was_verification_successful = 'yes' after construction skips validation. The existing components work the same way, so this matches the code but not CLAUDE.md. You could update CLAUDE.md to match the code, or leave both as they are.

馃 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.

We will leave this. The new fields follow the existing components, which also use attr_accessor.

Reply by Claude Opus 5.5 on Greg's behalf.


# @param params [Hash] Hash of parameters. Each key/value should
# correspond to one of the available attributes.
def initialize(params = {})
@first_name = params[:first_name]
@last_name = params[:last_name]
@company = params[:company]
@address = params[:address]
@address_2 = params[:address_2]
@city = params[:city]
@region = params[:region]
@country = params[:country]
@postal = params[:postal]
@phone_number = params[:phone_number]
@phone_country_code = params[:phone_country_code]
@first_name = params[:first_name]
@last_name = params[:last_name]
@company = params[:company]
@address = params[:address]
@address_2 = params[:address_2]
@city = params[:city]
@region = params[:region]
@country = params[:country]
@postal = params[:postal]
@phone_number = params[:phone_number]
@phone_country_code = params[:phone_country_code]
self.phone_verification_method = params[:phone_verification_method]
@phone_was_verification_successful = params[:phone_was_verification_successful]
@phone_verification_time = params[:phone_verification_time]

validate
end
Expand All @@ -108,6 +141,8 @@ def validate
validate_string('postal', 255, @postal)
validate_string('phone_number', 255, @phone_number)
validate_telephone_country_code('phone_country_code', @phone_country_code)
validate_boolean('phone_was_verification_successful', @phone_was_verification_successful)
validate_rfc3339('phone_verification_time', @phone_verification_time)
Comment thread
oschwald marked this conversation as 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.

Low: This checks only the RFC 3339 format of phone_verification_time. It does not check the range that STF-1906 and the doc comment above give: not before 1970-01-01T00:00:00Z and not more than two days in the future.

With validation on, Billing.new(phone_verification_time: '1500-01-01T00:00:00Z') and '9999-12-31T23:59:59Z' pass and are sent. The web service then ignores the field and returns only a warning, so a caller who turned validation on does not get an error.

If the client should catch this, add a range check (compare in UTC, with the upper bound as now + 2 days). If the range is left to the server on purpose, as for /event/time, consider saying so in the doc comment.

馃 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.

No client checks this range, the same as for /event/time. e7a2ab4 adds to the doc comment that the web service ignores a time outside the range and returns a warning.

Reply by Claude Opus 5.5 on Greg's behalf.

end
end
end
Expand Down
21 changes: 8 additions & 13 deletions lib/minfraud/components/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,27 +26,22 @@ def to_json(*_args)
#
# @return [Hash]
def populate!(hash, v_sym)
return hash unless (value = instance_variable_get(v_sym))
value = instance_variable_get(v_sym)
return hash if value.nil?

key = v_sym.to_s.gsub(/@/, '')
hash.merge!(key => represent(key, value))
key = v_sym.to_s.delete_prefix('@')
hash.merge!(key => represent(value))
end

# Return the value according to the request format.
#
# @param key [Symbol] An instance variable symbol.
# Return the value according to the request format. Booleans stay
# booleans. Other values become strings.
#
# @param value [Object] An instance variable value.
#
# @return [Object]
def represent(key, value)
BOOLS.include?(key) ? value : value.to_s
def represent(value)
Minfraud::Validates.boolean?(value) ? value : value.to_s
end

# Keys that have to remain boolean
BOOLS = %w[was_authorized is_gift has_gift_message].freeze

private_constant :BOOLS
end
end
end
1 change: 1 addition & 0 deletions lib/minfraud/components/device.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def validate
validate_string('accept_language', 255, @accept_language)
validate_nonnegative_number('session_age', @session_age)
validate_string('session_id', 255, @session_id)
validate_not_boolean('tracking_token', @tracking_token)
end
end
end
Expand Down
3 changes: 3 additions & 0 deletions lib/minfraud/components/report/transaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ def validate
validate_ip('ip_address', @ip_address)
validate_string('maxmind_id', 8, @maxmind_id)
validate_uuid('minfraud_id', @minfraud_id)
validate_not_boolean('chargeback_code', @chargeback_code)
validate_not_boolean('notes', @notes)
validate_not_boolean('transaction_id', @transaction_id)

if ip_address.nil? &&
(minfraud_id.nil? || empty_uuid?(minfraud_id)) &&
Expand Down
2 changes: 0 additions & 2 deletions lib/minfraud/components/shipping.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ module Components
#
# @see https://dev.maxmind.com/minfraud/api-documentation/requests/?lang=en#schema--request--shipping
class Shipping < Addressable
include ::Minfraud::Enum

# The shipping delivery speed for the order. Must be one of +:same_day+,
# +:overnight+, +:expedited+, or +:standard+.
#
Expand Down
36 changes: 27 additions & 9 deletions lib/minfraud/enum.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,22 @@ def self.included(base)
# types.
module ClassMethods
# Returns a hash in the following format: attribute_name =>
# permitted_values
# permitted_values. It includes the attributes declared on parent
# classes.
#
# @return [Hash]
def mapping
# rubocop:disable ThreadSafety/ClassInstanceVariable
# This is a false positive - this is set during class definition and then only read
@mapping ||= {}
# This is a false positive - this only reads the mapping that
# enum_accessor sets during class definition
declared = @declared_mapping || {}
# rubocop:enable ThreadSafety/ClassInstanceVariable

if superclass.respond_to?(:mapping)
superclass.mapping.merge(declared)
else
declared.dup
end
end

# Create a set of methods for enum-like behavior of the attribute.
Expand All @@ -35,23 +43,33 @@ def mapping
#
# @return [nil]
def enum_accessor(attribute, assignable_values)
mapping[attribute] = assignable_values.map(&:intern)
values = assignable_values.map(&:intern).freeze
declared_mapping[attribute] = values

self.class.instance_eval do
define_method("#{attribute}_values") { mapping[attribute] }
end
define_singleton_method("#{attribute}_values") { values }

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: *_values returns the same unfrozen array that the setter uses to check values, and mapping returns a shallow copy that shares those arrays. After Billing.phone_verification_method_values << :sms, Billing.new(phone_verification_method: :sms) is accepted and sends "sms". Freezing values when it is created (assignable_values.map(&:intern).freeze) prevents this. The old code had the same exposure through mapping, but this is an easy fix while the code changes.

馃 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 e51b315.

Reply by Claude Opus 5.5 on Greg's behalf.


class_eval do
define_method(attribute.to_s) { instance_variable_get("@#{attribute}") }
define_method "#{attribute}=" do |value|
raise NotEnumValueError, 'Value is not permitted' if value && !self.class.mapping[attribute].include?(value.intern)
value = value.intern if value.is_a?(String)
raise NotEnumValueError, 'Value is not permitted' if !value.nil? && !values.include?(value)

instance_variable_set("@#{attribute}", value ? value.intern : nil)
instance_variable_set("@#{attribute}", value)
end
end

nil
end

private

def declared_mapping
# rubocop:disable ThreadSafety/ClassInstanceVariable
# This is a false positive - only enum_accessor calls this, during class
# definition
@declared_mapping ||= {}

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 justification for the rubocop disable ("set during class definition and then only read") is no longer fully true. mapping calls declared_mapping, which writes @declared_mapping = {} at run time, on the first call, for a class that declares no enum of its own (for example Billing). mapping also builds a new merged hash on every call, where the old code returned a memoized hash. In mapping, reading @declared_mapping || {} would avoid the write at run time.

馃 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 e51b315. mapping now only reads @declared_mapping.

Reply by Claude Opus 5.5 on Greg's behalf.

# rubocop:enable ThreadSafety/ClassInstanceVariable
end
end
end
end
Loading
Loading