diff --git a/CHANGELOG.md b/CHANGELOG.md index b65991ea..0aedea9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,21 @@ CHANGELOG ========= +4.5.0 +------------------ + +* Added `phoneVerificationMethod`, `phoneWasVerificationSuccessful`, and + `phoneVerificationTime` to the `Billing` and `Shipping` request objects. + These describe the most recent verification of the phone number for the + address. The `PhoneVerificationMethod` enum has the values + `DELIVERED_CODE`, `NETWORK`, and `OTHER`. Do not set + `phoneWasVerificationSuccessful` if no verification was attempted. +* Fixed enum serialization and email address hashing in locales that + change case conversion, such as Turkish (`tr`) and Azerbaijani (`az`). + In those locales, some enum values serialized with a dotless `ı`, for + example `account_creatıon`, and the web service rejected them. The library + now uses `Locale.ROOT` for these case conversions. + 4.4.0 (2026-07-21) ------------------ diff --git a/CLAUDE.md b/CLAUDE.md index 043a2893..ea5612dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,14 +170,14 @@ public enum Status { @Override public String toString() { - return name().toLowerCase(); + return name().toLowerCase(Locale.ROOT); } } ``` **How it works:** - Enum constants use `UPPER_SNAKE_CASE` (e.g., `DNS_ERROR`, `ISP_EMAIL`) -- Override `toString()` to return `name().toLowerCase()` for JSON serialization +- Override `toString()` to return `name().toLowerCase(Locale.ROOT)` for JSON serialization - The `Mapper` class configures Jackson with: - `READ_ENUMS_USING_TO_STRING` - Deserializes using `toString()` - `READ_UNKNOWN_ENUM_VALUES_AS_NULL` - Unknown values become `null` instead of throwing exceptions diff --git a/README.md b/README.md index 9cbbd065..caffc206 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,9 @@ Transaction request = new Transaction.Builder( .lastName("Smith") .phoneCountryCode("1") .phoneNumber("321-321-3211") + .phoneVerificationMethod(Billing.PhoneVerificationMethod.DELIVERED_CODE) + .phoneVerificationTime(ZonedDateTime.parse("2026-10-01T14:30:00Z")) + .phoneWasVerificationSuccessful(true) .postal("06510") .region("CT") .build() @@ -254,6 +257,9 @@ Transaction request = new Transaction.Builder( .postal("55455") .phoneNumber("313-545-3113") .phoneCountryCode("1") + .phoneVerificationMethod(Shipping.PhoneVerificationMethod.NETWORK) + .phoneVerificationTime(ZonedDateTime.parse("2026-10-02T09:15:00Z")) + .phoneWasVerificationSuccessful(false) .deliverySpeed(Shipping.DeliverySpeed.EXPEDITED) .address("32 Washington Ave.") .address2("18") diff --git a/src/main/java/com/maxmind/minfraud/request/AbstractLocation.java b/src/main/java/com/maxmind/minfraud/request/AbstractLocation.java index 904676e0..6c0eae95 100644 --- a/src/main/java/com/maxmind/minfraud/request/AbstractLocation.java +++ b/src/main/java/com/maxmind/minfraud/request/AbstractLocation.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.maxmind.minfraud.AbstractModel; +import java.time.ZonedDateTime; +import java.util.Locale; import java.util.regex.Pattern; /** @@ -20,6 +22,9 @@ public abstract sealed class AbstractLocation extends AbstractModel private final String postal; private final String phoneNumber; private final String phoneCountryCode; + private final PhoneVerificationMethod phoneVerificationMethod; + private final Boolean phoneWasVerificationSuccessful; + private final ZonedDateTime phoneVerificationTime; protected AbstractLocation(AbstractLocation.Builder builder) { firstName = builder.firstName; @@ -33,6 +38,9 @@ protected AbstractLocation(AbstractLocation.Builder builder) { postal = builder.postal; phoneNumber = builder.phoneNumber; phoneCountryCode = builder.phoneCountryCode; + phoneVerificationMethod = builder.phoneVerificationMethod; + phoneWasVerificationSuccessful = builder.phoneWasVerificationSuccessful; + phoneVerificationTime = builder.phoneVerificationTime; } /** @@ -56,6 +64,9 @@ abstract static class Builder> { String postal; String phoneNumber; String phoneCountryCode; + PhoneVerificationMethod phoneVerificationMethod; + Boolean phoneWasVerificationSuccessful; + ZonedDateTime phoneVerificationTime; /** * @param name The first name associated with the address @@ -162,6 +173,37 @@ public final T phoneNumber(String number) { return (T) this; } + /** + * @param method The most recent method used to verify the phone number associated with + * the address + * @return The builder object. + */ + public final T phoneVerificationMethod(PhoneVerificationMethod method) { + phoneVerificationMethod = method; + return (T) this; + } + + /** + * @param wasSuccessful Whether the most recent verification of the phone number + * associated with the address succeeded. Do not set this if no + * verification was attempted. + * @return The builder object. + */ + public final T phoneWasVerificationSuccessful(Boolean wasSuccessful) { + phoneWasVerificationSuccessful = wasSuccessful; + return (T) this; + } + + /** + * @param time The date and time of the most recent verification of the phone number + * associated with the address + * @return The builder object. + */ + public final T phoneVerificationTime(ZonedDateTime time) { + phoneVerificationTime = time; + return (T) this; + } + abstract AbstractLocation build(); } @@ -257,4 +299,56 @@ public final String phoneNumber() { public final String phoneCountryCode() { return phoneCountryCode; } + + /** + * @return The most recent method used to verify the phone number associated with the address + */ + @JsonProperty("phone_verification_method") + public final PhoneVerificationMethod phoneVerificationMethod() { + return phoneVerificationMethod; + } + + /** + * @return Whether the most recent verification of the phone number associated with the + * address succeeded + */ + @JsonProperty("phone_was_verification_successful") + public final Boolean phoneWasVerificationSuccessful() { + return phoneWasVerificationSuccessful; + } + + /** + * @return The date and time of the most recent verification of the phone number associated + * with the address + */ + @JsonProperty("phone_verification_time") + public final ZonedDateTime phoneVerificationTime() { + return phoneVerificationTime; + } + + /** + * Enumerated phone verification methods. + */ + public enum PhoneVerificationMethod { + /** + * A code delivered to the phone, such as by SMS, voice call, or messaging app + */ + DELIVERED_CODE, + /** + * Verification through the mobile network operator, such as silent network + * authentication + */ + NETWORK, + /** + * Another verification method + */ + OTHER; + + /** + * @return a string representation of the object. + */ + public String toString() { + return this.name().toLowerCase(Locale.ROOT); + } + } } diff --git a/src/main/java/com/maxmind/minfraud/request/Email.java b/src/main/java/com/maxmind/minfraud/request/Email.java index c6159f42..5840821a 100644 --- a/src/main/java/com/maxmind/minfraud/request/Email.java +++ b/src/main/java/com/maxmind/minfraud/request/Email.java @@ -11,6 +11,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.Locale; import java.util.Map; import java.util.regex.Pattern; @@ -416,7 +417,7 @@ public String address() { } private String cleanAddress(String address) { - address = address.trim().toLowerCase(); + address = address.trim().toLowerCase(Locale.ROOT); int domainIndex = address.lastIndexOf('@'); if (domainIndex == -1 || domainIndex + 1 == address.length()) { diff --git a/src/main/java/com/maxmind/minfraud/request/Event.java b/src/main/java/com/maxmind/minfraud/request/Event.java index 1ece32fd..c838b239 100644 --- a/src/main/java/com/maxmind/minfraud/request/Event.java +++ b/src/main/java/com/maxmind/minfraud/request/Event.java @@ -6,6 +6,7 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.Date; +import java.util.Locale; /** * This class contains general information related to the event being scored. @@ -207,7 +208,7 @@ public enum Type { * @return a string representation of the object. */ public String toString() { - return this.name().toLowerCase(); + return this.name().toLowerCase(Locale.ROOT); } } @@ -228,7 +229,7 @@ public enum Party { * @return a string representation of the object. */ public String toString() { - return this.name().toLowerCase(); + return this.name().toLowerCase(Locale.ROOT); } } } \ No newline at end of file diff --git a/src/main/java/com/maxmind/minfraud/request/Payment.java b/src/main/java/com/maxmind/minfraud/request/Payment.java index dcbd8dda..54631913 100644 --- a/src/main/java/com/maxmind/minfraud/request/Payment.java +++ b/src/main/java/com/maxmind/minfraud/request/Payment.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.maxmind.minfraud.AbstractModel; +import java.util.Locale; /** * The payment information for the transaction. @@ -283,7 +284,7 @@ public enum Processor { * @return a string representation of the object. */ public String toString() { - return this.name().toLowerCase(); + return this.name().toLowerCase(Locale.ROOT); } } @@ -336,7 +337,7 @@ public enum Method { * @return a string representation of the object. */ public String toString() { - return this.name().toLowerCase(); + return this.name().toLowerCase(Locale.ROOT); } } } diff --git a/src/main/java/com/maxmind/minfraud/request/Shipping.java b/src/main/java/com/maxmind/minfraud/request/Shipping.java index 43ab51ad..e979f177 100644 --- a/src/main/java/com/maxmind/minfraud/request/Shipping.java +++ b/src/main/java/com/maxmind/minfraud/request/Shipping.java @@ -1,6 +1,7 @@ package com.maxmind.minfraud.request; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Locale; /** * The shipping information for the transaction. @@ -71,7 +72,7 @@ public enum DeliverySpeed { * @return a string representation of the object. */ public String toString() { - return this.name().toLowerCase(); + return this.name().toLowerCase(Locale.ROOT); } } } diff --git a/src/main/java/com/maxmind/minfraud/request/TransactionReport.java b/src/main/java/com/maxmind/minfraud/request/TransactionReport.java index 83c66f7f..e18f5f58 100644 --- a/src/main/java/com/maxmind/minfraud/request/TransactionReport.java +++ b/src/main/java/com/maxmind/minfraud/request/TransactionReport.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.maxmind.minfraud.AbstractModel; import java.net.InetAddress; +import java.util.Locale; import java.util.UUID; /** @@ -231,7 +232,7 @@ public enum Tag { * @return a string representation of the object. */ public String toString() { - return this.name().toLowerCase(); + return this.name().toLowerCase(Locale.ROOT); } } diff --git a/src/main/java/com/maxmind/minfraud/response/EmailDomain.java b/src/main/java/com/maxmind/minfraud/response/EmailDomain.java index 267c7661..efb869d0 100644 --- a/src/main/java/com/maxmind/minfraud/response/EmailDomain.java +++ b/src/main/java/com/maxmind/minfraud/response/EmailDomain.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.maxmind.minfraud.JsonSerializable; import java.time.LocalDate; +import java.util.Locale; /** * This class contains minFraud response data related to the email domain. @@ -63,7 +64,7 @@ public enum Classification { */ @Override public String toString() { - return name().toLowerCase(); + return name().toLowerCase(Locale.ROOT); } } diff --git a/src/main/java/com/maxmind/minfraud/response/EmailDomainVisit.java b/src/main/java/com/maxmind/minfraud/response/EmailDomainVisit.java index c3e1428b..3f3b7175 100644 --- a/src/main/java/com/maxmind/minfraud/response/EmailDomainVisit.java +++ b/src/main/java/com/maxmind/minfraud/response/EmailDomainVisit.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.maxmind.minfraud.JsonSerializable; import java.time.LocalDate; +import java.util.Locale; /** * This class contains information about an automated visit to the email domain. @@ -63,7 +64,7 @@ public enum Status { */ @Override public String toString() { - return name().toLowerCase(); + return name().toLowerCase(Locale.ROOT); } } diff --git a/src/test/java/com/maxmind/minfraud/LocaleTest.java b/src/test/java/com/maxmind/minfraud/LocaleTest.java new file mode 100644 index 00000000..eba90557 --- /dev/null +++ b/src/test/java/com/maxmind/minfraud/LocaleTest.java @@ -0,0 +1,65 @@ +package com.maxmind.minfraud; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.maxmind.minfraud.request.AbstractLocation; +import com.maxmind.minfraud.request.Email; +import com.maxmind.minfraud.request.Event; +import com.maxmind.minfraud.request.Payment; +import com.maxmind.minfraud.request.Shipping; +import com.maxmind.minfraud.request.TransactionReport; +import com.maxmind.minfraud.response.EmailDomain; +import com.maxmind.minfraud.response.EmailDomainVisit; +import java.util.List; +import java.util.Locale; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Checks that the default locale does not change serialized values. In the tr-TR locale, + * {@code "I".toLowerCase()} is a dotless "ı". + */ +public class LocaleTest { + private final Locale originalLocale = Locale.getDefault(); + + @BeforeEach + public void setTurkishLocale() { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + } + + @AfterEach + public void restoreLocale() { + Locale.setDefault(originalLocale); + } + + @Test + public void testEnumToString() { + var values = List.[]>of( + AbstractLocation.PhoneVerificationMethod.values(), + Shipping.DeliverySpeed.values(), + Payment.Processor.values(), + Payment.Method.values(), + TransactionReport.Tag.values(), + Event.Type.values(), + Event.Party.values(), + EmailDomain.Classification.values(), + EmailDomainVisit.Status.values() + ); + for (var enumValues : values) { + for (var value : enumValues) { + assertEquals( + value.name().toLowerCase(Locale.ROOT), + value.toString(), + value.getClass().getName() + "." + value.name() + ); + } + } + } + + @Test + public void testEmailAddressLowercasing() { + var email = new Email.Builder().address("INFO@TEST.org").hashAddress().build(); + assertEquals("fad0cdbc3804b804116ea8972221d2a1", email.address()); + } +} diff --git a/src/test/java/com/maxmind/minfraud/request/AbstractLocationTest.java b/src/test/java/com/maxmind/minfraud/request/AbstractLocationTest.java index b8f71ff4..08db206c 100644 --- a/src/test/java/com/maxmind/minfraud/request/AbstractLocationTest.java +++ b/src/test/java/com/maxmind/minfraud/request/AbstractLocationTest.java @@ -1,10 +1,14 @@ package com.maxmind.minfraud.request; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import com.maxmind.minfraud.request.AbstractLocation.Builder; +import com.maxmind.minfraud.request.AbstractLocation.PhoneVerificationMethod; +import java.time.ZonedDateTime; import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; public abstract class AbstractLocationTest { abstract Builder builder(); @@ -100,4 +104,58 @@ public void testPhoneCountryCode() { AbstractLocation loc = this.builder().phoneCountryCode("1").build(); assertEquals("1", loc.phoneCountryCode()); } + + @Test + public void testPhoneVerificationMethod() { + AbstractLocation loc = this.builder() + .phoneVerificationMethod(PhoneVerificationMethod.DELIVERED_CODE) + .build(); + assertEquals(PhoneVerificationMethod.DELIVERED_CODE, loc.phoneVerificationMethod()); + + loc = this.builder().phoneVerificationMethod(PhoneVerificationMethod.NETWORK).build(); + assertEquals(PhoneVerificationMethod.NETWORK, loc.phoneVerificationMethod()); + + loc = this.builder().phoneVerificationMethod(PhoneVerificationMethod.OTHER).build(); + assertEquals(PhoneVerificationMethod.OTHER, loc.phoneVerificationMethod()); + } + + @Test + public void testPhoneVerificationMethodToString() { + assertEquals("delivered_code", PhoneVerificationMethod.DELIVERED_CODE.toString()); + assertEquals("network", PhoneVerificationMethod.NETWORK.toString()); + assertEquals("other", PhoneVerificationMethod.OTHER.toString()); + } + + @Test + public void testPhoneWasVerificationSuccessful() { + AbstractLocation loc = this.builder().phoneWasVerificationSuccessful(false).build(); + assertFalse(loc.phoneWasVerificationSuccessful()); + } + + @Test + public void testPhoneVerificationTime() { + var time = ZonedDateTime.now(); + AbstractLocation loc = this.builder().phoneVerificationTime(time).build(); + assertEquals(time, loc.phoneVerificationTime()); + } + + @Test + public void testPhoneVerificationSerialization() throws Exception { + AbstractLocation loc = this.builder() + .phoneVerificationMethod(PhoneVerificationMethod.NETWORK) + .phoneWasVerificationSuccessful(false) + .phoneVerificationTime(ZonedDateTime.parse("2026-10-01T14:30:00Z")) + .build(); + + var expectedJSON = "{phone_verification_method:'network'," + + "phone_was_verification_successful:false," + + "phone_verification_time:'2026-10-01T14:30:00Z'}"; + JSONAssert.assertEquals(expectedJSON, loc.toJson(), true); + } + + @Test + public void testPhoneVerificationOmittedWhenUnset() throws Exception { + AbstractLocation loc = this.builder().build(); + JSONAssert.assertEquals("{}", loc.toJson(), true); + } } diff --git a/src/test/java/com/maxmind/minfraud/request/RequestTestHelper.java b/src/test/java/com/maxmind/minfraud/request/RequestTestHelper.java index 606ca5bd..1c20de68 100644 --- a/src/test/java/com/maxmind/minfraud/request/RequestTestHelper.java +++ b/src/test/java/com/maxmind/minfraud/request/RequestTestHelper.java @@ -86,6 +86,9 @@ private static Transaction makeTransaction(Email e) throws Exception { .postal("06510") .phoneNumber("123-456-7890") .phoneCountryCode("1") + .phoneVerificationMethod(Billing.PhoneVerificationMethod.DELIVERED_CODE) + .phoneWasVerificationSuccessful(true) + .phoneVerificationTime(ZonedDateTime.parse("2026-10-01T14:30:00Z")) .build() ).shipping( new Shipping.Builder() @@ -100,6 +103,9 @@ private static Transaction makeTransaction(Email e) throws Exception { .postal("73003") .phoneNumber("123-456-0000") .phoneCountryCode("1") + .phoneVerificationMethod(Shipping.PhoneVerificationMethod.NETWORK) + .phoneWasVerificationSuccessful(false) + .phoneVerificationTime(ZonedDateTime.parse("2026-10-02T09:15:00Z")) .deliverySpeed(Shipping.DeliverySpeed.SAME_DAY) .build() ).payment( diff --git a/src/test/resources/test-data/full-request-email-md5.json b/src/test/resources/test-data/full-request-email-md5.json index 97150805..361ad816 100644 --- a/src/test/resources/test-data/full-request-email-md5.json +++ b/src/test/resources/test-data/full-request-email-md5.json @@ -25,7 +25,10 @@ "country": "US", "postal": "06510", "phone_number": "123-456-7890", - "phone_country_code": "1" + "phone_country_code": "1", + "phone_verification_method": "delivered_code", + "phone_was_verification_successful": true, + "phone_verification_time": "2026-10-01T14:30:00Z" }, "shipping": { "first_name": "ShipFirst", @@ -39,6 +42,9 @@ "postal": "73003", "phone_number": "123-456-0000", "phone_country_code": "1", + "phone_verification_method": "network", + "phone_was_verification_successful": false, + "phone_verification_time": "2026-10-02T09:15:00Z", "delivery_speed": "same_day" }, "payment": { diff --git a/src/test/resources/test-data/full-request.json b/src/test/resources/test-data/full-request.json index 09658526..17fb0b9e 100644 --- a/src/test/resources/test-data/full-request.json +++ b/src/test/resources/test-data/full-request.json @@ -25,7 +25,10 @@ "country": "US", "postal": "06510", "phone_number": "123-456-7890", - "phone_country_code": "1" + "phone_country_code": "1", + "phone_verification_method": "delivered_code", + "phone_was_verification_successful": true, + "phone_verification_time": "2026-10-01T14:30:00Z" }, "shipping": { "first_name": "ShipFirst", @@ -39,6 +42,9 @@ "postal": "73003", "phone_number": "123-456-0000", "phone_country_code": "1", + "phone_verification_method": "network", + "phone_was_verification_successful": false, + "phone_verification_time": "2026-10-02T09:15:00Z", "delivery_speed": "same_day" }, "payment": {