diff --git a/CHANGELOG.md b/CHANGELOG.md index 35555b18..85326a08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,34 +1,39 @@ CHANGELOG ========= -2.1.0 +3.0.0 ------------------ +* This library no longer uses `Respect\Validation`. +* The `with*` methods can now be used with named arguments instead of + an array. This provides better editor completion, type checking, + and documentation. +* Email normalization has been improved: + * Equivalent domain names are now normalized when `hashEmail` is used. + For example, `googlemail.com` will become `gmail.com`. + * Periods are now removed from `gmail.com` email address local parts when + `hashEmail` is used. For example, `f.o.o@gmail.com` will become + `foo@gmail.com`. + * Fastmail alias subdomain email addresses are now normalized when + `hashEmail` is used. For example, `alias@user.fastmail.com` will become + `user@fastmail.com`. + * Additional `yahoo.com` email addresses now have aliases removed from + their local part when `hashEmail` is used. For example, + `foo-bar@yahoo.com` will become `foo@yahoo.com` for additional + `yahoo.com` domains. + * Duplicate `.com`s are now removed from email domain names when + `hashEmail` is used. For example, `example.com.com` will become + `example.com`. + * Certain TLD typos are now normalized when `hashEmail` is used. For + example, `example.comcom` will become `example.com`. + * Additional `gmail.com` domain names with leading digits are now + normalized when `hashEmail` is used. For example, `100gmail.com` will + become `gmail.com`. + * Additional `gmail.com` typos are now normalized when `hashEmail` is used. + For example, `gmali.com` will become `gmail.com`. + * When `hashEmail` is used, the local part of an email address is now + normalized to NFC. * Added `pxp_financial` and `trustpay` to the payment processor validation. -* Equivalent domain names are now normalized when `hashEmail` is used. - For example, `googlemail.com` will become `gmail.com`. -* Periods are now removed from `gmail.com` email address local parts when - `hashEmail` is used. For example, `f.o.o@gmail.com` will become - `foo@gmail.com`. -* Fastmail alias subdomain email addresses are now normalized when - `hashEmail` is used. For example, `alias@user.fastmail.com` will become - `user@fastmail.com`. -* Additional `yahoo.com` email addresses now have aliases removed from - their local part when `hashEmail` is used. For example, - `foo-bar@yahoo.com` will become `foo@yahoo.com` for additional - `yahoo.com` domains. -* Duplicate `.com`s are now removed from email domain names when - `hashEmail` is used. For example, `example.com.com` will become - `example.com`. -* Certain TLD typos are now normalized when `hashEmail` is used. For - example, `example.comcom` will become `example.com`. -* Additional `gmail.com` domain names with leading digits are now - normalized when `hashEmail` is used. For example, `100gmail.com` will - become `gmail.com`. -* Additional `gmail.com` typos are now normalized when `hashEmail` is used. - For example, `gmali.com` will become `gmail.com`. -* When `hashEmail` is used, the local part of an email address is now - normalized to NFC. 2.0.0 (2023-12-04) ------------------ @@ -36,7 +41,7 @@ CHANGELOG * IMPORTANT: PHP 8.1 or greater is now required. * BREAKING: Read-only properties are now used for the model class rather than magic methods. -* BREAKING: The `rawResponse` property on model classess has been removed. Use +* BREAKING: The `rawResponse` property on model classes has been removed. Use the `jsonSerialize` method instead. * BREAKING: The inheritance hierarchy on model classes has changed. * Updated `geoip2/geoip2` to version that includes the `isAnycast` property on diff --git a/README.md b/README.md index 2c24a965..9c834a74 100644 --- a/README.md +++ b/README.md @@ -147,86 +147,85 @@ $mf = new MinFraud(1, 'ABCD567890'); # Note that each ->with*() call returns a new immutable object. This means # that if you separate the calls into separate statements without chaining, # you should assign the return value to a variable each time. -$request = $mf->withDevice([ - 'ip_address' => '152.216.7.110', - 'session_age' => 3600.5, - 'session_id' => 'foobar', - 'user_agent' => - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.89 Safari/537.36', - 'accept_language' => 'en-US,en;q=0.8', -])->withEvent([ - 'transaction_id' => 'txn3134133', - 'shop_id' => 's2123', - 'time' => '2012-04-12T23:20:50+00:00', - 'type' => 'purchase', -])->withAccount([ - 'user_id' => 3132, - 'username_md5' => '4f9726678c438914fa04bdb8c1a24088', -])->withEmail([ - 'address' => 'test@maxmind.com', - 'domain' => 'maxmind.com', -])->withBilling([ - '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', -])->withShipping([ - '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', -])->withPayment([ - 'processor' => 'stripe', - 'was_authorized' => false, - 'decline_code' => 'invalid number', -])->withCreditCard([ - 'issuer_id_number' => '411111', - 'last_digits' => '7643', - 'bank_name' => 'Bank of No Hope', - 'bank_phone_country_code' => '1', - 'bank_phone_number' => '123-456-1234', - 'avs_result' => 'Y', - 'cvv_result' => 'N', - 'was_3d_secure_successful' => true, -])->withOrder([ - 'amount' => 323.21, - 'currency' => 'USD', - 'discount_code' => 'FIRST', - 'is_gift' => true, - 'has_gift_message' => false, - 'affiliate_id' => 'af12', - 'subaffiliate_id' => 'saf42', - 'referrer_uri' => 'http://www.amazon.com/', -])->withShoppingCartItem([ - 'category' => 'pets', - 'item_id' => 'leash-0231', - 'quantity' => 2, - 'price' => 20.43, -])->withShoppingCartItem([ - 'category' => 'beauty', - 'item_id' => 'msc-1232', - 'quantity' => 1, - 'price' => 100.00, -])->withCustomInputs([ - 'section' => 'news', - 'previous_purchases' => 19, - 'discount' => 3.2, - 'previous_user' => true, +$request = $mf->withDevice( + ipAddress: '152.216.7.110', + sessionAge: 3600.5, + sessionId: 'foobar', + userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.89 Safari/537.36', + acceptLanguage: 'en-US,en;q=0.8' +)->withEvent( + transactionId: 'txn3134133', + shopId: 's2123', + time: '2012-04-12T23:20:50+00:00', + type: 'purchase' +)->withAccount( + userId: 3132, + usernameMd5: '4f9726678c438914fa04bdb8c1a24088' +)->withEmail( + address: 'test@maxmind.com', + domain: 'maxmind.com' +)->withBilling( + firstName: 'First', + lastName: 'Last', + company: 'Company', + address: '101 Address Rd.', + address2: 'Unit 5', + city: 'New Haven', + region: 'CT', + country: 'US', + postal: '06510', + phoneNumber: '123-456-7890', + phoneCountryCode: '1' +)->withShipping( + firstName: 'ShipFirst', + lastName: 'ShipLast', + company: 'ShipCo', + address: '322 Ship Addr. Ln.', + address2: 'St. 43', + city: 'Nowhere', + region: 'OK', + country: 'US', + postal: '73003', + phoneNumber: '123-456-0000', + phoneCountryCode: '1', + deliverySpeed: 'same_day' +)->withPayment( + processor: 'stripe', + wasAuthorized: false, + declineCode: 'invalid number' +)->withCreditCard( + issuerIdNumber: '411111', + lastDigits: '7643', + bankName: 'Bank of No Hope', + bankPhoneCountryCode: '1', + bankPhoneNumber: '123-456-1234', + avsResult: 'Y', + cvvResult: 'N', + was3dSecureSuccessful: true +)->withOrder( + amount: 323.21, + currency: 'USD', + discountCode: 'FIRST', + isGift: true, + hasGiftMessage: false, + affiliateId: 'af12', + subaffiliateId: 'saf42', + referrerUri: 'http://www.amazon.com/' +)->withShoppingCartItem( + category: 'pets', + itemId: 'leash-0231', + quantity: 2, + price: 20.43 +)->withShoppingCartItem( + category: 'beauty', + itemId: 'msc-1232', + quantity: 1, + price: 100.00 +)->withCustomInputs([ + 'section' => 'news', + 'previous_purchases' => 19, + 'discount' => 3.2, + 'previous_user' => true, ]); # To get the minFraud Factors response model, use ->factors(): @@ -309,15 +308,15 @@ use MaxMind\MinFraud\ReportTransaction; # and optionally an array of options. $rt = new ReportTransaction(1, 'ABCD567890'); -$rt->report([ - 'ip_address' => '152.216.7.110', - 'tag' => 'chargeback', - 'chargeback_code' => 'UA02', - 'minfraud_id' => '26ae87e4-5112-4f76-b0f7-4132d45d72b2', - 'maxmind_id' => 'aBcDeFgH', - 'notes' => 'Found due to non-existent shipping address', - 'transaction_id' => 'cart123456789', -]); +$rt->report( + ipAddress: '152.216.7.110', + tag: 'chargeback', + chargebackCode: 'UA02', + minfraudId: '26ae87e4-5112-4f76-b0f7-4132d45d72b2', + maxmindId: 'aBcDeFgH', + notes: 'Found due to non-existent shipping address', + transactionId: 'cart123456789' +); ``` ## Support ## diff --git a/composer.json b/composer.json index 1e22117c..b0cb9f41 100644 --- a/composer.json +++ b/composer.json @@ -20,8 +20,7 @@ "php": ">=8.1", "ext-json": "*", "geoip2/geoip2": "^v3.0.0", - "maxmind/web-service-common": "^0.9.0", - "respect/validation": "^2.3.1" + "maxmind/web-service-common": "^0.9.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "3.*", diff --git a/dev-bin/release.sh b/dev-bin/release.sh index 2656a9d7..3db30d05 100755 --- a/dev-bin/release.sh +++ b/dev-bin/release.sh @@ -58,7 +58,7 @@ php box.phar compile phar_test=$(./dev-bin/phar-test.php) if [[ -n $phar_test ]]; then - echo "Phar test outputed non-empty string: $phar_test" + echo "Phar test outputted non-empty string: $phar_test" exit 1 fi diff --git a/src/MinFraud.php b/src/MinFraud.php index f8b94002..dab89c09 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -61,29 +61,29 @@ class MinFraud extends MinFraud\ServiceClient * @param string $licenseKey Your MaxMind license key * @param array $options An array of options. Possible keys: * - * * `host` - The host to use when connecting to the web service. - * By default, the client connects to the production host. However, - * during testing and development, you can set this option to - * 'sandbox.maxmind.com' to use the Sandbox environment's host. The - * sandbox allows you to experiment with the API without affecting your - * production data. - * * `userAgent` - The prefix for the User-Agent header to use in the - * request. - * * `caBundle` - The bundle of CA root certificates to use in the request. - * * `connectTimeout` - The connect timeout to use for the request. - * * `hashEmail` - By default, the email address is sent in plain text. - * If this is set to `true`, the email address will be normalized and - * converted to an MD5 hash before the request is sent. The email domain - * will continue to be sent in plain text. - * * `timeout` - The timeout to use for the request. - * * `proxy` - The HTTP proxy to use. May include a schema, port, - * username, and password, e.g., `http://username:password@127.0.0.1:10`. - * * `locales` - An array of locale codes to use for the location name - * properties. - * * `validateInput` - Default is `true`. Determines whether values passed - * to the `with*()` methods are validated. It is recommended that you - * leave validation on while developing and only (optionally) disable it - * before deployment. + * - `host` - The host to use when connecting to the web service. + * By default, the client connects to the production host. However, + * during testing and development, you can set this option to + * 'sandbox.maxmind.com' to use the Sandbox environment's host. The + * sandbox allows you to experiment with the API without affecting your + * production data. + * - `userAgent` - The prefix for the User-Agent header to use in the + * request. + * - `caBundle` - The bundle of CA root certificates to use in the request. + * - `connectTimeout` - The connect timeout to use for the request. + * - `hashEmail` - By default, the email address is sent in plain text. + * If this is set to `true`, the email address will be normalized and + * converted to an MD5 hash before the request is sent. The email domain + * will continue to be sent in plain text. + * - `timeout` - The timeout to use for the request. + * - `proxy` - The HTTP proxy to use. May include a schema, port, + * username, and password, e.g., `http://username:password@127.0.0.1:10`. + * - `locales` - An array of locale codes to use for the location name + * properties. + * - `validateInput` - Default is `true`. Determines whether values passed + * to the `with*()` methods are validated. It is recommended that you + * leave validation on while developing and only (optionally) disable it + * before deployment. */ public function __construct( int $accountId, @@ -113,82 +113,366 @@ public function __construct( */ public function with(array $values): self { - $values = $this->cleanAndValidate('Transaction', $values); - - if ($this->hashEmail) { - $values = Util::maybeHashEmail($values); + $new = $this; + if (\array_key_exists('account', $values)) { + $new = $new->withAccount($this->remove($values, 'account', ['array'])); + } + if (\array_key_exists('billing', $values)) { + $new = $new->withBilling($this->remove($values, 'billing', ['array'])); + } + if (\array_key_exists('credit_card', $values)) { + $new = $new->withCreditCard($this->remove($values, 'credit_card', ['array'])); + } + if (\array_key_exists('custom_inputs', $values)) { + $new = $new->withCustomInputs($this->remove($values, 'custom_inputs', ['array'])); + } + if (\array_key_exists('device', $values)) { + $new = $new->withDevice($this->remove($values, 'device', ['array'])); + } + if (\array_key_exists('email', $values)) { + $new = $new->withEmail($this->remove($values, 'email', ['array'])); + } + if (\array_key_exists('event', $values)) { + $new = $new->withEvent($this->remove($values, 'event', ['array'])); + } + if (\array_key_exists('order', $values)) { + $new = $new->withOrder($this->remove($values, 'order', ['array'])); + } + if (\array_key_exists('payment', $values)) { + $new = $new->withPayment($this->remove($values, 'payment', ['array'])); + } + if (\array_key_exists('shipping', $values)) { + $new = $new->withShipping($this->remove($values, 'shipping', ['array'])); + } + if (\array_key_exists('shopping_cart', $values)) { + foreach ($this->remove($values, 'shopping_cart', ['array']) as $item) { + $new = $new->withShoppingCartItem($item); + } } - $new = clone $this; - $new->content = $values; + $this->verifyEmpty($values); return $new; } /** * This returns a `MinFraud` object with the `device` array set to - * `$values`. Existing `device` data will be replaced. + * the values provided. Existing `device` data will be replaced. + * + * @param array $values An array of device data. The keys are the same as + * the JSON keys. You may use either this or the named + * arguments, but not both. + * @param string|null $ipAddress The IP address associated with the device + * used by the customer in the transaction. + * The IP address must be in IPv4 or IPv6 + * presentation format, i.e., dotted-quad + * notation or the IPv6 hexadecimal-colon + * notation. + * @param string|null $userAgent The HTTP `User-Agent` header of the browser + * used in the transaction + * @param string|null $acceptLanguage The HTTP `Accept-Language` header of + * the device used in the transaction + * @param float|null $sessionAge The number of seconds between the creation + * of the user's session and the time of the + * transaction. Note that `session_age` is not + * the duration of the current visit, but the + * time since the start of the first visit. + * @param string|null $sessionId An ID that uniquely identifies a visitor's + * session on the site + * + * @return MinFraud A new immutable MinFraud object. This object is a clone + * of the original with additional data. * * @link https://dev.maxmind.com/minfraud/api-documentation/requests?lang=en#schema--request--device * minFraud device API docs - * - * @return MinFraud A new immutable MinFraud object. This object is - * a clone of the original with additional data. */ - public function withDevice(array $values): self - { - return $this->validateAndAdd('Device', 'device', $values); + public function withDevice( + array $values = [], + ?string $acceptLanguage = null, + ?string $ipAddress = null, + ?float $sessionAge = null, + ?string $sessionId = null, + ?string $userAgent = null, + ): self { + if (\count($values) !== 0) { + if (\func_num_args() !== 1) { + throw new \InvalidArgumentException( + 'You may only provide the $values array or named arguments, not both.', + ); + } + $acceptLanguage = $this->remove($values, 'accept_language'); + $ipAddress = $this->remove($values, 'ip_address'); + if (($v = $this->remove($values, 'session_age', ['double', 'float', 'integer', 'string'])) && $v !== null) { + if (!is_numeric($v)) { + $this->maybeThrowInvalidInputException('Expected session_age to be a number'); + } + $sessionAge = (float) $v; + } + if (isset($values['session_id'])) { + if (($v = $this->remove($values, 'session_id', ['integer', 'string'])) && $v !== null) { + $sessionId = (string) $v; + } + } + if ($sessionId) { + $userAgent = $this->remove($values, 'user_agent'); + } + + $this->verifyEmpty($values); + } + + if ($acceptLanguage !== null) { + $values['accept_language'] = $acceptLanguage; + } + + if ($ipAddress !== null) { + if (!filter_var($ipAddress, \FILTER_VALIDATE_IP)) { + $this->maybeThrowInvalidInputException("$ipAddress is an invalid IP address"); + } + $values['ip_address'] = $ipAddress; + } + + if ($sessionAge !== null) { + if ($sessionAge < 0) { + $this->maybeThrowInvalidInputException("Session age ($sessionAge) must be greater than or equal to 0"); + } + $values['session_age'] = $sessionAge; + } + + if ($sessionId !== null) { + if (!\is_string($sessionId) + || $sessionId === '' + || \strlen($sessionId) > 255) { + $this->maybeThrowInvalidInputException( + "Session ID ($sessionId) must be a string with length between 1 and 255", + ); + } + $values['session_id'] = $sessionId; + } + + if ($userAgent !== null) { + $values['user_agent'] = $userAgent; + } + + $new = clone $this; + $new->content['device'] = $values; + + return $new; } /** - * This returns a `MinFraud` object with the `events` array set to - * `$values`. Existing `event` data will be replaced. + * This returns a `MinFraud` object with the `event` array set to + * the values provided. Existing `event` data will be replaced. + * + * @param array $values An array of event data. The keys are the same as + * the JSON keys. You may use either this or the named + * arguments, but not both. + * @param string|null $shopId Your internal ID for the shop, affiliate, or + * merchant this order is coming from. Required for + * minFraud users who are resellers, payment + * providers, gateways and affiliate networks. No + * specific format is required. + * @param string|null $time The date and time the event occurred. The string + * must be in the RFC 3339 date-time format. The time + * must be within the past year. If this field is not + * in the request, the current time will be used. + * @param string|null $transactionId Your internal ID for the transaction. We + * can use this to locate a specific + * transaction in our logs, and it will also + * show up in email alerts and notifications + * from us to you. No specific format is + * required. + * @param string|null $type The type of event being scored. The valid types + * are: + * - `account_creation` + * - `account_login` + * - `email_change` + * - `password_reset` + * - `payout_change` + * - `purchase` + * - `recurring_purchase` + * - `referral` + * - `survey` + * + * @return MinFraud A new immutable MinFraud object. This object is a clone of + * the original with additional data. * * @link https://dev.maxmind.com/minfraud/api-documentation/requests?lang=en#schema--request--event * minFraud event API docs - * - * @return MinFraud A new immutable MinFraud object. This object is - * a clone of the original with additional data. */ - public function withEvent(array $values): self - { - return $this->validateAndAdd('Event', 'event', $values); + public function withEvent( + array $values = [], + ?string $shopId = null, + ?string $time = null, + ?string $transactionId = null, + ?string $type = null, + ): self { + if (\count($values) !== 0) { + if (\func_num_args() !== 1) { + throw new \InvalidArgumentException( + 'You may only provide the $values array or named arguments, not both.', + ); + } + $shopId = $this->remove($values, 'shop_id'); + $time = $this->remove($values, 'time'); + $transactionId = $this->remove($values, 'transaction_id'); + $type = $this->remove($values, 'type'); + + $this->verifyEmpty($values); + } + + if ($shopId !== null) { + $values['shop_id'] = $shopId; + } + + if ($time !== null) { + if (\DateTime::createFromFormat(\DateTime::RFC3339, $time) === false + && \DateTime::createFromFormat(\DateTime::RFC3339_EXTENDED, $time) === false + ) { + $this->maybeThrowInvalidInputException("$time is not a valid RFC 3339 formatted datetime string"); + } + + $values['time'] = $time; + } + + if ($transactionId !== null) { + $values['transaction_id'] = $transactionId; + } + + if ($type !== null) { + if (!\in_array($type, [ + 'account_creation', + 'account_login', + 'email_change', + 'password_reset', + 'payout_change', + 'purchase', + 'recurring_purchase', + 'referral', + 'survey', + ], true)) { + $this->maybeThrowInvalidInputException("$type is not a valid event type"); + } + $values['type'] = $type; + } + + $new = clone $this; + $new->content['event'] = $values; + + return $new; } /** * This returns a `MinFraud` object with the `account` array set to - * `$values`. Existing `account` data will be replaced. + * the values provided. Existing `` data will be replaced. * * @link https://dev.maxmind.com/minfraud/api-documentation/requests?lang=en#schema--request--account * minFraud account API docs * - * @return MinFraud A new immutable MinFraud object. This object is - * a clone of the original with additional data. + * @param array $values An array of account data. The keys are the same as + * the JSON keys. You may use either this or the named + * arguments, but not both. + * @param string|null $userId a unique user ID associated with the end-user + * in your system + * @param string|null $usernameMd5 an MD5 hash as a hexadecimal string of + * the username or login name associated + * with the account + * + * @return MinFraud A new immutable MinFraud object. This object is a clone + * of the original with additional data. */ - public function withAccount(array $values): self - { - return $this->validateAndAdd('Account', 'account', $values); + public function withAccount( + array $values = [], + ?string $userId = null, + ?string $usernameMd5 = null, + ): self { + if (\count($values) !== 0) { + if (\func_num_args() !== 1) { + throw new \InvalidArgumentException( + 'You may only provide the $values array or named arguments, not both.', + ); + } + $userId = $this->remove($values, 'user_id'); + $usernameMd5 = $this->remove($values, 'username_md5'); + + $this->verifyEmpty($values); + } + + if ($userId !== null) { + $values['user_id'] = $userId; + } + + if ($usernameMd5 !== null) { + if (!preg_match('/^[a-fA-F0-9]{32}$/', $usernameMd5)) { + $this->maybeThrowInvalidInputException("$usernameMd5 must be an MD5"); + } + $values['username_md5'] = $usernameMd5; + } + + $new = clone $this; + $new->content['account'] = $values; + + return $new; } /** * This returns a `MinFraud` object with the `email` array set to - * `$values`. Existing `email` data will be replaced. + * values provided. Existing `email` data will be replaced. * * @link https://dev.maxmind.com/minfraud/api-documentation/requests?lang=en#schema--request--email * minFraud email API docs * - * @return MinFraud A new immutable MinFraud object. This object is - * a clone of the original with additional data. + * @param array $values An array of email data. The keys are the same as + * the JSON keys. You may use either this or the named + * arguments, but not both. + * @param string|null $address The email address used in the transaction. + * This field must be a valid email address. + * @param string|null $domain The domain of the email address used in the + * transaction. Do not include the `@` in this + * field. + * + * @return MinFraud A new immutable MinFraud object. This object is a clone + * of the original with additional data. */ - public function withEmail(array $values): self - { - $obj = $this->validateAndAdd('Email', 'email', $values); + public function withEmail( + array $values = [], + ?string $address = null, + ?string $domain = null, + ): self { + if (\count($values) !== 0) { + if (\func_num_args() !== 1) { + throw new \InvalidArgumentException( + 'You may only provide the $values array or named arguments, not both.', + ); + } + $address = $this->remove($values, 'address'); + $domain = $this->remove($values, 'domain'); + + $this->verifyEmpty($values); + } + + if ($address !== null) { + if (!filter_var($address, \FILTER_VALIDATE_EMAIL) + && !preg_match('/^[a-fA-F0-9]{32}$/', $address)) { + $this->maybeThrowInvalidInputException("$address is an invalid email address or MD5"); + } + $values['address'] = $address; + } + + if ($domain !== null) { + if (!filter_var($domain, \FILTER_VALIDATE_DOMAIN, \FILTER_FLAG_HOSTNAME) || !str_contains($domain, '.')) { + $this->maybeThrowInvalidInputException("$domain is an invalid domain name"); + } + $values['domain'] = $domain; + } + + $new = clone $this; + $new->content['email'] = $values; if ($this->hashEmail) { - $obj->content = Util::maybeHashEmail($obj->content); + $new->content = Util::maybeHashEmail($new->content); } - return $obj; + return $new; } /** @@ -198,59 +482,615 @@ public function withEmail(array $values): self * @link https://dev.maxmind.com/minfraud/api-documentation/requests?lang=en#schema--request--billing * minFraud billing API docs * - * @return MinFraud A new immutable MinFraud object. This object is - * a clone of the original with additional data. + * @param array $values An array of billing data. The keys are the same as + * the JSON keys. You may use either this or the named + * arguments, but not both. + * @param string|null $address The first line of the user's billing address + * @param string|null $address2 The second line of the user's billing address + * @param string|null $city The city of the user's billing address + * @param string|null $company The company of the end user as provided in + * their billing information + * @param string|null $country The two character ISO 3166-1 alpha-2 country + * code of the user's billing address + * @param string|null $firstName The first name of the end user as provided + * in their billing information + * @param string|null $lastName The last name of the end user as provided + * in their billing information + * @param string|null $phoneCountryCode The country code for phone number + * associated with the user's billing + * address. If you provide this + * information then you must provide + * at least one digit. + * @param string|null $phoneNumber The phone number without the country code + * for the user's billing address. Punctuation + * characters will be stripped. After + * stripping punctuation characters, the + * number must contain only digits. + * @param string|null $postal The postal code of the user's billing address + * @param string|null $region The ISO 3166-2 subdivision code for the user's + * billing address + * + * @return MinFraud A new immutable MinFraud object. This object is a clone + * of the original with additional data. */ - public function withBilling(array $values): self - { - return $this->validateAndAdd('Billing', 'billing', $values); + public function withBilling( + array $values = [], + ?string $address = null, + ?string $address2 = null, + ?string $city = null, + ?string $company = null, + ?string $country = null, + ?string $firstName = null, + ?string $lastName = null, + ?string $phoneCountryCode = null, + ?string $phoneNumber = null, + ?string $postal = null, + ?string $region = null, + ): self { + if (\count($values) !== 0) { + if (\func_num_args() !== 1) { + throw new \InvalidArgumentException( + 'You may only provide the $values array or named arguments, not both.', + ); + } + + $address = $this->remove($values, 'address'); + $address2 = $this->remove($values, 'address_2'); + $city = $this->remove($values, 'city'); + $company = $this->remove($values, 'company'); + $country = $this->remove($values, 'country'); + $firstName = $this->remove($values, 'first_name'); + $lastName = $this->remove($values, 'last_name'); + $phoneCountryCode = $this->remove($values, 'phone_country_code'); + $phoneNumber = $this->remove($values, 'phone_number'); + $postal = $this->remove($values, 'postal'); + $region = $this->remove($values, 'region'); + + $this->verifyEmpty($values); + } + + if ($address !== null) { + $values['address'] = $address; + } + + if ($address2 !== null) { + $values['address_2'] = $address2; + } + + if ($city !== null) { + $values['city'] = $city; + } + + if ($company !== null) { + $values['company'] = $company; + } + + if ($country !== null) { + $this->verifyCountryCode($country); + $values['country'] = $country; + } + + if ($firstName !== null) { + $values['first_name'] = $firstName; + } + + if ($lastName !== null) { + $values['last_name'] = $lastName; + } + + if ($phoneCountryCode !== null) { + $this->verifyPhoneCountryCode($phoneCountryCode); + $values['phone_country_code'] = $phoneCountryCode; + } + + if ($phoneNumber !== null) { + $values['phone_number'] = $phoneNumber; + } + + if ($postal !== null) { + $values['postal'] = $postal; + } + + if ($region !== null) { + $this->verifyRegionCode($region); + $values['region'] = $region; + } + + $new = clone $this; + $new->content['billing'] = $values; + + return $new; } /** * This returns a `MinFraud` object with the `shipping` array set to - * `$values`. Existing `shipping` data will be replaced. + * the values provided. Existing `shipping` data will be replaced. * * @link https://dev.maxmind.com/minfraud/api-documentation/requests?lang=en#schema--request--shipping * minFraud shipping API docs * + * @param array $values An array of shipping data. The keys are the same as + * the JSON keys. You may use either this or the named + * arguments, but not both. + * @param string|null $company The company of the end user as provided in + * their shipping information + * @param string|null $address The first line of the user's shipping address + * @param string|null $city The city of the user's shipping address + * @param string|null $region The ISO 3166-2 subdivision code for the user's + * shipping address + * @param string|null $country The two character ISO 3166-1 alpha-2 country + * code of the user's shipping address + * @param string|null $postal The postal code of the user's shipping address + * * @return MinFraud A new immutable MinFraud object. This object is * a clone of the original with additional data. */ - public function withShipping(array $values): self - { - return $this->validateAndAdd('Shipping', 'shipping', $values); + public function withShipping( + array $values = [], + ?string $address = null, + ?string $address2 = null, + ?string $city = null, + ?string $company = null, + ?string $country = null, + ?string $deliverySpeed = null, + ?string $firstName = null, + ?string $lastName = null, + ?string $phoneCountryCode = null, + ?string $phoneNumber = null, + ?string $postal = null, + ?string $region = null, + ): self { + if (\count($values) !== 0) { + if (\func_num_args() !== 1) { + throw new \InvalidArgumentException( + 'You may only provide the $values array or named arguments, not both.', + ); + } + + $address = $this->remove($values, 'address'); + $address2 = $this->remove($values, 'address_2'); + $city = $this->remove($values, 'city'); + $company = $this->remove($values, 'company'); + $country = $this->remove($values, 'country'); + $deliverySpeed = $this->remove($values, 'delivery_speed'); + $firstName = $this->remove($values, 'first_name'); + $lastName = $this->remove($values, 'last_name'); + $phoneCountryCode = $this->remove($values, 'phone_country_code'); + $phoneNumber = $this->remove($values, 'phone_number'); + $postal = $this->remove($values, 'postal'); + $region = $this->remove($values, 'region'); + + $this->verifyEmpty($values); + } + + if ($address !== null) { + $values['address'] = $address; + } + + if ($address2 !== null) { + $values['address_2'] = $address2; + } + + if ($city !== null) { + $values['city'] = $city; + } + + if ($company !== null) { + $values['company'] = $company; + } + + if ($country !== null) { + $this->verifyCountryCode($country); + $values['country'] = $country; + } + + if ($deliverySpeed !== null) { + if (!\in_array($deliverySpeed, ['same_day', 'overnight', 'expedited', 'standard'], true)) { + $this->maybeThrowInvalidInputException("$deliverySpeed is not a valid delivery speed"); + } + + $values['delivery_speed'] = $deliverySpeed; + } + + if ($firstName !== null) { + $values['first_name'] = $firstName; + } + + if ($lastName !== null) { + $values['last_name'] = $lastName; + } + + if ($phoneCountryCode !== null) { + $this->verifyPhoneCountryCode($phoneCountryCode); + $values['phone_country_code'] = $phoneCountryCode; + } + + if ($phoneNumber !== null) { + $values['phone_number'] = $phoneNumber; + } + + if ($postal !== null) { + $values['postal'] = $postal; + } + + if ($region !== null) { + $this->verifyRegionCode($region); + $values['region'] = $region; + } + + $new = clone $this; + $new->content['shipping'] = $values; + + return $new; } /** * This returns a `MinFraud` object with the `payment` array set to - * `$values`. Existing `payment` data will be replaced. + * the values provided. Existing `payment` data will be replaced. * * @link https://dev.maxmind.com/minfraud/api-documentation/requests?lang=en#schema--request--payment * minFraud payment API docs * + * @param array $values An array of payment data. The keys are the same as + * the JSON keys. You may use either this or the named + * arguments, but not both. + * @param string|null $declineCode The decline code as provided by your + * payment processor. If the transaction + * was not declined, do not include this field. + * @param string|null $processor The payment processor used for the transaction + * @param bool|null $wasAuthorized The authorization outcome from the payment + * processor. If the transaction has not yet been + * approved or denied, do not include this field. + * * @return MinFraud A new immutable MinFraud object. This object is * a clone of the original with additional data. */ - public function withPayment(array $values): self - { - return $this->validateAndAdd('Payment', 'payment', $values); + public function withPayment( + array $values = [], + ?string $declineCode = null, + ?string $processor = null, + ?bool $wasAuthorized = null, + ): self { + if (\count($values) !== 0) { + if (\func_num_args() !== 1) { + throw new \InvalidArgumentException( + 'You may only provide the $values array or named arguments, not both.', + ); + } + + $declineCode = $this->remove($values, 'decline_code'); + $processor = $this->remove($values, 'processor'); + $wasAuthorized = $this->remove($values, 'was_authorized', ['boolean']); + + $this->verifyEmpty($values); + } + + if ($declineCode !== null) { + $values['decline_code'] = $declineCode; + } + + if ($processor !== null) { + if (!\in_array($processor, [ + 'adyen', + 'affirm', + 'afterpay', + 'altapay', + 'amazon_payments', + 'american_express_payment_gateway', + 'apple_pay', + 'aps_payments', + 'authorizenet', + 'balanced', + 'beanstream', + 'bluepay', + 'bluesnap', + 'boacompra', + 'boku', + 'bpoint', + 'braintree', + 'cardknox', + 'cardpay', + 'cashfree', + 'ccavenue', + 'ccnow', + 'cetelem', + 'chase_paymentech', + 'checkout_com', + 'cielo', + 'collector', + 'commdoo', + 'compropago', + 'concept_payments', + 'conekta', + 'coregateway', + 'creditguard', + 'credorax', + 'ct_payments', + 'cuentadigital', + 'curopayments', + 'cybersource', + 'dalenys', + 'dalpay', + 'datacap', + 'datacash', + 'dibs', + 'digital_river', + 'dlocal', + 'dotpay', + 'ebs', + 'ecomm365', + 'ecommpay', + 'elavon', + 'emerchantpay', + 'epay', + 'eprocessing_network', + 'epx', + 'eway', + 'exact', + 'first_atlantic_commerce', + 'first_data', + 'fiserv', + 'g2a_pay', + 'global_payments', + 'gocardless', + 'google_pay', + 'heartland', + 'hipay', + 'ingenico', + 'interac', + 'internetsecure', + 'intuit_quickbooks_payments', + 'iugu', + 'klarna', + 'komoju', + 'lemon_way', + 'mastercard_payment_gateway', + 'mercadopago', + 'mercanet', + 'merchant_esolutions', + 'mirjeh', + 'mollie', + 'moneris_solutions', + 'neopay', + 'neosurf', + 'nmi', + 'oceanpayment', + 'oney', + 'onpay', + 'openbucks', + 'openpaymx', + 'optimal_payments', + 'orangepay', + 'other', + 'pacnet_services', + 'payeezy', + 'payfast', + 'paygate', + 'paylike', + 'payment_express', + 'paymentwall', + 'payone', + 'paypal', + 'payplus', + 'paysafecard', + 'paysera', + 'paystation', + 'paytm', + 'paytrace', + 'paytrail', + 'payture', + 'payu', + 'payulatam', + 'payvision', + 'payway', + 'payza', + 'pinpayments', + 'placetopay', + 'posconnect', + 'princeton_payment_solutions', + 'psigate', + 'pxp_financial', + 'qiwi', + 'quickpay', + 'raberil', + 'razorpay', + 'rede', + 'redpagos', + 'rewardspay', + 'safecharge', + 'sagepay', + 'securetrading', + 'shopify_payments', + 'simplify_commerce', + 'skrill', + 'smartcoin', + 'smartdebit', + 'solidtrust_pay', + 'sps_decidir', + 'stripe', + 'synapsefi', + 'systempay', + 'telerecargas', + 'towah', + 'transact_pro', + 'trustly', + 'trustpay', + 'tsys', + 'usa_epay', + 'vantiv', + 'verepay', + 'vericheck', + 'vindicia', + 'virtual_card_services', + 'vme', + 'vpos', + 'windcave', + 'wirecard', + 'worldpay', + ], true)) { + $this->maybeThrowInvalidInputException("$processor is not a valid payment processor"); + } + $values['processor'] = $processor; + } + + if ($wasAuthorized !== null) { + $values['was_authorized'] = $wasAuthorized; + } + + $new = clone $this; + $new->content['payment'] = $values; + + return $new; } /** * This returns a `MinFraud` object with the `credit_card` array set to - * `$values`. Existing `credit_card` data will be replaced. + * provided values. Existing `credit_card` data will be replaced. * * @link https://dev.maxmind.com/minfraud/api-documentation/requests?lang=en#schema--request--credit-card * minFraud credit_card API docs * - * @return MinFraud A new immutable MinFraud object. This object is - * a clone of the original with additional data. + * @param array $values An array of credit card data. The keys are the same as + * the JSON keys. You may use either this or the named + * arguments, but not both. + * @param string|null $avsResult The address verification system (AVS) check + * result, as returned to you by the credit card + * processor + * @param string|null $bankName The name of the issuing bank as provided by the + * end user + * @param string|null $bankPhoneCountryCode The phone country code for the + * issuing bank as provided by the end + * user + * @param string|null $bankPhoneNumber The phone number, without the country + * code, for the issuing bank as provided by + * the end user + * @param string|null $country The two character ISO 3166-1 alpha-2 country + * code where the issuer of the card is located + * @param string|null $cvvResult The card verification value (CVV) code as + * provided by the payment processor + * @param string|null $issuerIdNumber The issuer ID number for the credit card. + * This is the first six or eight digits of + * the credit card number. It identifies the + * issuing bank. + * @param string|null $lastDigits The last digits of the credit card number. + * In most cases, you should send the last four + * digits for `lastDigits`. + * @param string|null $token A token uniquely identifying the card + * @param bool|null $was3dSecureSuccessful Whether the outcome of 3-D Secure + * verification was successful + * + * @return MinFraud A new immutable MinFraud object. This object is a clone of + * the original with additional data. */ - public function withCreditCard(array $values): self - { - $values = Util::cleanCreditCard($values); + public function withCreditCard( + array $values = [], + ?string $avsResult = null, + ?string $bankName = null, + ?string $bankPhoneCountryCode = null, + ?string $bankPhoneNumber = null, + ?string $country = null, + ?string $cvvResult = null, + ?string $issuerIdNumber = null, + ?string $lastDigits = null, + ?string $token = null, + ?bool $was3dSecureSuccessful = null, + ): self { + if (\count($values) !== 0) { + if (\func_num_args() !== 1) { + throw new \InvalidArgumentException( + 'You may only provide the $values array or named arguments, not both.', + ); + } - return $this->validateAndAdd('CreditCard', 'credit_card', $values); + $values = Util::cleanCreditCard($values); + + $avsResult = $this->remove($values, 'avs_result'); + $bankName = $this->remove($values, 'bank_name'); + $bankPhoneCountryCode = $this->remove($values, 'bank_phone_country_code'); + $bankPhoneNumber = $this->remove($values, 'bank_phone_number'); + $country = $this->remove($values, 'country'); + $cvvResult = $this->remove($values, 'cvv_result'); + $issuerIdNumber = $this->remove($values, 'issuer_id_number'); + $lastDigits = $this->remove($values, 'last_digits'); + $token = $this->remove($values, 'token'); + $was3dSecureSuccessful = $this->remove($values, 'was_3d_secure_successful', ['boolean']); + + $this->verifyEmpty($values); + } + + if ($avsResult !== null) { + if (\strlen($avsResult) !== 1) { + $this->maybeThrowInvalidInputException('AVS result must be a string of length 1.'); + } + $values['avs_result'] = $avsResult; + } + + if ($bankName !== null) { + $values['bank_name'] = $bankName; + } + + if ($bankPhoneCountryCode !== null) { + if (!preg_match('/^[0-9]{1,4}$/', $bankPhoneCountryCode)) { + $this->maybeThrowInvalidInputException('Bank phone country code must be a string of 1 to 4 digits.'); + } + + $values['bank_phone_country_code'] = $bankPhoneCountryCode; + } + + if ($bankPhoneNumber !== null) { + $values['bank_phone_number'] = $bankPhoneNumber; + } + + if ($country !== null) { + $this->verifyCountryCode($country); + $values['country'] = $country; + } + + if ($cvvResult !== null) { + if (\strlen($cvvResult) !== 1) { + $this->maybeThrowInvalidInputException('CVV result must be a string of length 1.'); + } + $values['cvv_result'] = $cvvResult; + } + + if ($issuerIdNumber !== null) { + if (!preg_match('/^(?:[0-9]{6}|[0-9]{8})$/', $issuerIdNumber)) { + $this->maybeThrowInvalidInputException('Issuer ID number must be a string of 6 or 8 digits.'); + } + $values['issuer_id_number'] = $issuerIdNumber; + } + + if ($lastDigits !== null) { + if (!preg_match('/^(?:[0-9]{2}|[0-9]{4})$/', $lastDigits)) { + $this->maybeThrowInvalidInputException('Last digits must be a string of 2 or 4 digits.'); + } + $values['last_digits'] = $lastDigits; + } + + if ($token !== null) { + if (!preg_match('/^[\x21-\x7E]{1,255}$/', $token)) { + $this->maybeThrowInvalidInputException( + 'Credit card token must be a string of 1 to 255 printable ASCII characters.', + ); + } + + if (preg_match('/^[0-9]{1,19}$/', $token)) { + $this->maybeThrowInvalidInputException( + 'Credit card token cannot look like a card number or part of one.', + ); + } + + $values['token'] = $token; + } + + if ($was3dSecureSuccessful !== null) { + $values['was_3d_secure_successful'] = $was3dSecureSuccessful; + } + + $new = clone $this; + $new->content['credit_card'] = $values; + + return $new; } /** @@ -262,37 +1102,210 @@ public function withCreditCard(array $values): self */ public function withCustomInputs(array $values): self { - return $this->validateAndAdd('CustomInputs', 'custom_inputs', $values); + foreach ($values as $key => $value) { + if (\is_string($value)) { + if (str_contains($value, "\n")) { + $this->maybeThrowInvalidInputException( + "$value is invalid. String custom input values must not contain newline characters.", + ); + } + if ($value === '' || \strlen($value) > 255) { + $this->maybeThrowInvalidInputException( + "$value is invalid. String custom input values must have a length between 1 and 255.", + ); + } + } elseif (is_numeric($value)) { + if ($value < -1e13 + 1 || $value > 1e13 - 1) { + $this->maybeThrowInvalidInputException( + "$value is invalid. Numeric custom input values must be between -1e13 and 1e13.", + ); + } + } elseif (!\is_bool($value)) { + $this->maybeThrowInvalidInputException( + "$value is invalid. Custom input values must be strings, numbers, or booleans.", + ); + } + + if (!preg_match('/^[a-z0-9_]{1,25}\Z/', $key)) { + $this->maybeThrowInvalidInputException( + "$key is invalid. Custom input keys must be alphanumeric and have 25 characters or less.", + ); + } + } + + $new = clone $this; + $new->content['custom_inputs'] = $values; + + return $new; } /** * This returns a `MinFraud` object with the `order` array set to - * `$values`. Existing `order` data will be replaced. + * the provided values. Existing `order` data will be replaced. * * @link https://dev.maxmind.com/minfraud/api-documentation/requests?lang=en#schema--request--order * minFraud order API docs * - * @return MinFraud A new immutable MinFraud object. This object is - * a clone of the original with additional data. + * @param array $values An array of order data. The keys are the same as the JSON keys. + * You may use either this or the named arguments, but not both. + * @param string|null $affiliateId The ID of the affiliate where the order is coming from. + * No specific format is required. + * @param float|null $amount The total order amount + * @param string|null $currency The currency code for the order amount + * @param string|null $discountCode The discount code applied to the order + * @param bool|null $hasGiftMessage Indicates if the order has a gift message + * @param bool|null $isGift Indicates if the order is a gift + * @param string|null $referrerUri The URI of the referring website + * @param string|null $subaffiliateId The ID of the sub-affiliate where the order is coming from. + * No specific format is required. + * + * @return MinFraud A new immutable MinFraud object. This object is a clone of the original with additional data. + * + * @see https://support.maxmind.com/hc/en-us/articles/5452293435675-Order-and-Shopping-Cart-Inputs#h_01G0Z50Q0MRXQ5R52EF34E6G7J */ - public function withOrder(array $values): self - { - return $this->validateAndAdd('Order', 'order', $values); + public function withOrder( + array $values = [], + ?string $affiliateId = null, + ?float $amount = null, + ?string $currency = null, + ?string $discountCode = null, + ?bool $hasGiftMessage = null, + ?bool $isGift = null, + ?string $referrerUri = null, + ?string $subaffiliateId = null, + ): self { + if (\count($values) !== 0) { + if (\func_num_args() !== 1) { + throw new \InvalidArgumentException( + 'You may only provide the $values array or named arguments, not both.', + ); + } + + $affiliateId = $this->remove($values, 'affiliate_id'); + $amount = $this->remove($values, 'amount', ['double', 'float', 'integer']); + $currency = $this->remove($values, 'currency'); + $discountCode = $this->remove($values, 'discount_code'); + $hasGiftMessage = $this->remove($values, 'has_gift_message', ['boolean']); + $isGift = $this->remove($values, 'is_gift', ['boolean']); + $referrerUri = $this->remove($values, 'referrer_uri'); + $subaffiliateId = $this->remove($values, 'subaffiliate_id'); + + $this->verifyEmpty($values); + } + + if ($affiliateId !== null) { + $values['affiliate_id'] = $affiliateId; + } + + if ($amount !== null) { + if ($amount < 0) { + $this->maybeThrowInvalidInputException("$amount must be greater than or equal to 0"); + } + $values['amount'] = $amount; + } + + if ($currency !== null) { + if (!preg_match('/^[A-Z]{3}$/', $currency)) { + $this->maybeThrowInvalidInputException("$currency is not a valid currency code"); + } + $values['currency'] = $currency; + } + + if ($discountCode !== null) { + $values['discount_code'] = $discountCode; + } + + if ($hasGiftMessage !== null) { + $values['has_gift_message'] = $hasGiftMessage; + } + + if ($isGift !== null) { + $values['is_gift'] = $isGift; + } + + if ($referrerUri !== null) { + if (!filter_var($referrerUri, \FILTER_VALIDATE_URL)) { + $this->maybeThrowInvalidInputException("$referrerUri is not a valid URL"); + } + $values['referrer_uri'] = $referrerUri; + } + + if ($subaffiliateId !== null) { + $values['subaffiliate_id'] = $subaffiliateId; + } + + $new = clone $this; + $new->content['order'] = $values; + + return $new; } /** - * This returns a `MinFraud` object with `$values` added to the shopping - * cart array. + * This returns a `MinFraud` object with the provided values added to the + * shopping cart array. Existing shopping cart data will be preserved. * * @link https://dev.maxmind.com/minfraud/api-documentation/requests?lang=en#schema--request--shopping-cart--item * minFraud shopping cart item API docs * - * @return MinFraud A new immutable MinFraud object. This object is - * a clone of the original with additional data. + * @param array $values An array of shopping cart data. The keys are the same + * as the JSON keys. You may use either this or the named + * arguments, but not both. + * @param string|null $category The category of the item. This can also be + * a hashed value; see below. + * @param float|null $price The per-unit price of this item in the shopping + * cart. This should use the same currency as the + * order currency. + * @param int|null $quantity The quantity of the item in the shopping cart. + * The value must be a whole number. + * + * @return MinFraud A new immutable MinFraud object. This object is a clone + * of the original with additional data. */ - public function withShoppingCartItem(array $values): self - { - $values = $this->cleanAndValidate('ShoppingCartItem', $values); + public function withShoppingCartItem( + array $values = [], + ?string $category = null, + ?string $itemId = null, + ?float $price = null, + ?int $quantity = null, + ): self { + if (\count($values) !== 0) { + if (\func_num_args() !== 1) { + throw new \InvalidArgumentException( + 'You may only provide the $values array or named arguments, not both.', + ); + } + + $category = $this->remove($values, 'category'); + if (($v = (string) $this->remove($values, 'item_id', ['integer', 'string'])) && $v !== null) { + $itemId = $v; + } + $price = $this->remove($values, 'price', ['double', 'float', 'integer']); + $quantity = $this->remove($values, 'quantity', ['integer']); + + $this->verifyEmpty($values); + } + + if ($category !== null) { + $values['category'] = $category; + } + + if ($itemId !== null) { + $values['item_id'] = $itemId; + } + + if ($price !== null) { + if ($price < 0) { + $this->maybeThrowInvalidInputException("$price must be greater than or equal to 0"); + } + $values['price'] = $price; + } + + if ($quantity !== null) { + if ($quantity < 0) { + $this->maybeThrowInvalidInputException("$quantity must be greater than or equal to 0"); + } + $values['quantity'] = $quantity; + } $new = clone $this; if (!isset($new->content['shopping_cart'])) { @@ -303,22 +1316,6 @@ public function withShoppingCartItem(array $values): self return $new; } - /** - * @param string $className The name of the class (but not the namespace) - * @param string $key The key in the transaction array to set - * @param array $values The values to validate - * - * @throws InvalidInputException when $values does not validate - */ - private function validateAndAdd(string $className, string $key, array $values): self - { - $values = $this->cleanAndValidate($className, $values); - $new = clone $this; - $new->content[$key] = $values; - - return $new; - } - /** * This method performs a minFraud Score lookup using the request data in * the current object and returns a model object for minFraud Score. @@ -413,4 +1410,27 @@ private function post(string $class, string $path) $this->locales ); } + + private function verifyCountryCode(string $country): void + { + if (!preg_match('/^[A-Z]{2}$/', $country)) { + $this->maybeThrowInvalidInputException("$country is not a valid ISO 3166-1 country code"); + } + } + + private function verifyPhoneCountryCode(string $phoneCountryCode): void + { + if (!preg_match('/^[0-9]{1,4}$/', $phoneCountryCode)) { + $this->maybeThrowInvalidInputException('Phone country code must be a string of 1 to 4 digits.'); + } + } + + private function verifyRegionCode(string $region): void + { + if (!preg_match('/^[0-9A-Z]{1,4}$/', $region)) { + $this->maybeThrowInvalidInputException( + "$region is not a valid ISO 3166-2 region code (without country prefix)", + ); + } + } } diff --git a/src/MinFraud/ReportTransaction.php b/src/MinFraud/ReportTransaction.php index 9db34838..d52e6e2a 100644 --- a/src/MinFraud/ReportTransaction.php +++ b/src/MinFraud/ReportTransaction.php @@ -39,7 +39,42 @@ public function __construct( } /** - * @param array $values the transaction parameters + * This call allows you to report transactions to MaxMind for use in + * updating the fraud score of future queries. The transaction should have + * been previously submitted to minFraud. + * + * @param array $values An array of transaction parameters. The keys are the same + * as the JSON keys. You may use either this or the named + * arguments, but not both. + * @param string $ipAddress Required. The IP address of the customer placing the + * order. This should be passed as a string like + * "44.55.66.77" or "2001:db8::2:1". + * @param string $tag Required. A string indicating the likelihood that a + * transaction may be fraudulent. Possible values: + * not_fraud, suspected_fraud, spam_or_abuse, or + * chargeback. + * @param string $chargebackCode Optional. A string which is provided by your payment + * processor indicating the reason for the chargeback. + * @param string $maxmindId Optional. A unique eight character string identifying + * a minFraud Standard or Premium request. These IDs are + * returned in the maxmindID field of a response for a + * successful minFraud request. This field is not + * required, but you are encouraged to provide it, if + * possible. + * @param string $minfraudId Optional. A UUID that identifies a minFraud Score, + * minFraud Insights, or minFraud Factors request. This + * ID is returned at /id in the response. This field is + * not required, but you are encouraged to provide it if + * the request was made to one of these services. + * @param string $notes Optional. Your notes on the fraud tag associated with + * the transaction. We manually review many reported + * transactions to improve our scoring for you so any + * additional details to help us understand context are + * helpful. + * @param string $transactionId Optional. The transaction ID you originally passed to + * minFraud. This field is not required, but you are + * encouraged to provide it or the transaction's + * maxmind_id or minfraud_id. * * @throws InvalidInputException when the request has missing or invalid * data @@ -53,15 +88,82 @@ public function __construct( * serves as the base class for the above * exceptions. */ - public function report(array $values): void - { - $values = $this->cleanAndValidate('TransactionReport', $values); + public function report( + array $values = [], + ?string $chargebackCode = null, + ?string $ipAddress = null, + ?string $maxmindId = null, + ?string $minfraudId = null, + ?string $notes = null, + ?string $tag = null, + ?string $transactionId = null + ): void { + if (\count($values) !== 0) { + if (\func_num_args() !== 1) { + throw new \InvalidArgumentException( + 'You may only provide the $values array or named arguments, not both.', + ); + } + + $chargebackCode = $this->remove($values, 'chargeback_code'); + $ipAddress = $this->remove($values, 'ip_address'); + $maxmindId = $this->remove($values, 'maxmind_id'); + $minfraudId = $this->remove($values, 'minfraud_id'); + $notes = $this->remove($values, 'notes'); + $tag = $this->remove($values, 'tag'); + $transactionId = $this->remove($values, 'transaction_id'); + + $this->verifyEmpty($values); + } + + if ($chargebackCode !== null) { + $values['chargeback_code'] = $chargebackCode; + } + + if ($ipAddress === null) { + // This is required so we always throw an exception if it is not set + throw new InvalidInputException('An IP address is required'); + } + if (!filter_var($ipAddress, \FILTER_VALIDATE_IP)) { + $this->maybeThrowInvalidInputException("$ipAddress is an invalid IP address"); + } + $values['ip_address'] = $ipAddress; - if (!isset($values['ip_address'])) { - throw new InvalidInputException('Key ip_address must be present in request'); + if ($maxmindId !== null) { + if (\strlen($maxmindId) !== 8) { + $this->maybeThrowInvalidInputException("$maxmindId must be 8 characters long"); + } + $values['maxmind_id'] = $maxmindId; } - if (!isset($values['tag'])) { - throw new InvalidInputException('Key tag must be present in request'); + + if ($minfraudId !== null) { + if (!preg_match( + '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i', + $minfraudId, + )) { + $this->maybeThrowInvalidInputException("$minfraudId must be a valid minFraud ID"); + } + + $values['minfraud_id'] = $minfraudId; + } + + if ($notes !== null) { + $values['notes'] = $notes; + } + + if ($tag === null) { + // This is required so we always throw an exception if it is not set + throw new InvalidInputException('A tag is required'); + } + if (!\in_array($tag, ['not_fraud', 'suspected_fraud', 'spam_or_abuse', 'chargeback'], true)) { + $this->maybeThrowInvalidInputException( + "$tag must be one of 'not_fraud', 'suspected_fraud', 'spam_or_abuse', or 'chargeback'", + ); + } + $values['tag'] = $tag; + + if ($transactionId !== null) { + $values['transaction_id'] = $transactionId; } $url = self::$basePath . 'transactions/report'; diff --git a/src/MinFraud/ServiceClient.php b/src/MinFraud/ServiceClient.php index 5609709a..abb1b95c 100644 --- a/src/MinFraud/ServiceClient.php +++ b/src/MinFraud/ServiceClient.php @@ -6,7 +6,6 @@ use MaxMind\Exception\InvalidInputException; use MaxMind\WebService\Client; -use Respect\Validation\Exceptions\ValidationException; abstract class ServiceClient { @@ -56,45 +55,35 @@ protected function userAgent(): string return 'minFraud-API/' . self::VERSION; } - /** - * @param string $className The name of the class (but not the namespace) - * @param array $values The values to validate - * - * @throws InvalidInputException when $values does not validate - * - * @return array The cleaned values - */ - protected function cleanAndValidate(string $className, array $values): array + protected function maybeThrowInvalidInputException(string $msg): void { - $values = $this->clean($values); - - if (!$this->validateInput) { - return $values; + if ($this->validateInput) { + throw new InvalidInputException($msg); } + } - $class = '\\MaxMind\\MinFraud\\Validation\\Rules\\' . $className; - $validator = new $class(); + protected function remove(array &$array, string $key, array $types = ['string']): mixed + { + if (\array_key_exists($key, $array)) { + $value = $array[$key]; + $actualType = \gettype($value); + if ($value !== null && !\in_array($actualType, $types, true)) { + $this->maybeThrowInvalidInputException( + "Expected $key to be in [" . implode(', ', $types) . "] but was $actualType", + ); + } + unset($array[$key]); - try { - $validator->check($values); - } catch (ValidationException $exception) { - throw new InvalidInputException($exception->getMessage(), $exception->getCode()); + return $value; } - return $values; + return null; } - protected function clean(array $array): array + protected function verifyEmpty(array $values): void { - $cleaned = []; - foreach ($array as $key => $value) { - if (\is_array($value)) { - $cleaned[$key] = $this->clean($array[$key]); - } elseif ($array[$key] !== null) { - $cleaned[$key] = $array[$key]; - } + if (\count($values) !== 0) { + $this->maybeThrowInvalidInputException('Unknown keys in array: ' . implode(', ', array_keys($values))); } - - return $cleaned; } } diff --git a/src/MinFraud/Validation/Exceptions/Md5Exception.php b/src/MinFraud/Validation/Exceptions/Md5Exception.php deleted file mode 100644 index 86ba0ea1..00000000 --- a/src/MinFraud/Validation/Exceptions/Md5Exception.php +++ /dev/null @@ -1,19 +0,0 @@ - [ - self::STANDARD => '{{name}} must be an MD5', - ], - ]; -} diff --git a/src/MinFraud/Validation/Exceptions/SubdivisionIsoCodeException.php b/src/MinFraud/Validation/Exceptions/SubdivisionIsoCodeException.php deleted file mode 100644 index 068955ba..00000000 --- a/src/MinFraud/Validation/Exceptions/SubdivisionIsoCodeException.php +++ /dev/null @@ -1,19 +0,0 @@ - [ - self::STANDARD => '{{name}} must be an ISO 3166-2 subdivision code', - ], - ]; -} diff --git a/src/MinFraud/Validation/Exceptions/TelephoneCountryCodeException.php b/src/MinFraud/Validation/Exceptions/TelephoneCountryCodeException.php deleted file mode 100644 index 5b5cb809..00000000 --- a/src/MinFraud/Validation/Exceptions/TelephoneCountryCodeException.php +++ /dev/null @@ -1,19 +0,0 @@ - [ - self::STANDARD => '{{name}} must be a valid telephone country code', - ], - ]; -} diff --git a/src/MinFraud/Validation/Rules/Account.php b/src/MinFraud/Validation/Rules/Account.php deleted file mode 100644 index 77448d38..00000000 --- a/src/MinFraud/Validation/Rules/Account.php +++ /dev/null @@ -1,22 +0,0 @@ -length(1, 1), false), - v::key('bank_name', v::stringType(), false), - v::key('bank_phone_country_code', new TelephoneCountryCode(), false), - v::key('bank_phone_number', v::stringType(), false), - v::key('country', v::allOf(v::countryCode(), v::uppercase()), false), - v::key('cvv_result', v::stringType()->length(1, 1), false), - v::key('issuer_id_number', v::regex('/^(?:[0-9]{6}|[0-9]{8})$/'), false), - v::key('last_digits', v::regex('/^(?:[0-9]{2}|[0-9]{4})$/'), false), - v::key('token', v::regex('/^[\x21-\x7E]{1,255}$/')->not(v::regex('/^[0-9]{1,19}$/')), false), - v::key('was_3d_secure_successful', v::boolVal(), false), - ) - ); - } -} diff --git a/src/MinFraud/Validation/Rules/CustomInputs.php b/src/MinFraud/Validation/Rules/CustomInputs.php deleted file mode 100644 index 3b893b7a..00000000 --- a/src/MinFraud/Validation/Rules/CustomInputs.php +++ /dev/null @@ -1,30 +0,0 @@ -each( - v::anyOf( - v::stringType()->not(v::contains("\n"))->length(1, 255), - v::numericVal()->max(1e13 - 1)->min(-1e13 + 1), - v::boolType() - ), - ), - v::call('array_keys', v::each(v::regex('/^[a-z0-9_]{1,25}\Z/'))), - ), - ); - } -} diff --git a/src/MinFraud/Validation/Rules/Device.php b/src/MinFraud/Validation/Rules/Device.php deleted file mode 100644 index 7d4a46b5..00000000 --- a/src/MinFraud/Validation/Rules/Device.php +++ /dev/null @@ -1,25 +0,0 @@ -length(1, 255), false), - v::key('session_age', v::floatVal()->min(0), false), - v::key('user_agent', v::stringType(), false) - )); - } -} diff --git a/src/MinFraud/Validation/Rules/Email.php b/src/MinFraud/Validation/Rules/Email.php deleted file mode 100644 index 20076ea9..00000000 --- a/src/MinFraud/Validation/Rules/Email.php +++ /dev/null @@ -1,22 +0,0 @@ -min(0), false), - v::key('currency', v::regex('/^[A-Z]{3}$/'), false), - v::key('discount_code', v::stringType(), false), - v::key('has_gift_message', v::boolVal(), false), - v::key('is_gift', v::boolVal(), false), - v::key('referrer_uri', v::url(), false), - v::key('subaffiliate_id', v::stringType(), false) - )); - } -} diff --git a/src/MinFraud/Validation/Rules/Payment.php b/src/MinFraud/Validation/Rules/Payment.php deleted file mode 100644 index acd3d2a7..00000000 --- a/src/MinFraud/Validation/Rules/Payment.php +++ /dev/null @@ -1,23 +0,0 @@ -min(0), false), - v::key('quantity', v::intVal()->greaterThan(0), false) - )); - } -} diff --git a/src/MinFraud/Validation/Rules/SubdivisionIsoCode.php b/src/MinFraud/Validation/Rules/SubdivisionIsoCode.php deleted file mode 100644 index 5d7d4d71..00000000 --- a/src/MinFraud/Validation/Rules/SubdivisionIsoCode.php +++ /dev/null @@ -1,19 +0,0 @@ -each(new ShoppingCartItem()), false) - ), - v::arrayVal()->length(1, null), - ), - ); - } -} diff --git a/src/MinFraud/Validation/Rules/TransactionReport.php b/src/MinFraud/Validation/Rules/TransactionReport.php deleted file mode 100644 index ba374609..00000000 --- a/src/MinFraud/Validation/Rules/TransactionReport.php +++ /dev/null @@ -1,42 +0,0 @@ -length(8, 8), false), - v::key( - 'minfraud_id', - v::regex('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i'), - false - ), - v::key('notes', v::stringType(), false), - v::key( - 'tag', - v::in( - [ - 'not_fraud', - 'suspected_fraud', - 'spam_or_abuse', - 'chargeback', - ] - ), - true - ), - v::key('transaction_id', v::stringType(), false) - )); - } -} diff --git a/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php b/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php index 1635c635..76982c9d 100644 --- a/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php +++ b/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php @@ -68,7 +68,7 @@ public function testRequestsWithNulls(): void public function testMissingRequiredFields(array $req): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('Must have keys'); + $this->expectExceptionMessageMatches('/Expected|is required/'); $this->createReportTransactionRequest( $req, @@ -82,7 +82,7 @@ public function testMissingRequiredFields(array $req): void public function testMissingRequiredFieldsWithoutValidation(array $req): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must be present in request'); + $this->expectExceptionMessageMatches('/Expected|is required/'); $this->createReportTransactionRequest( $req, @@ -112,7 +112,7 @@ public static function requestsMissingRequiredFields(): array public function testUnknownKey(): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('Must not have keys'); + $this->expectExceptionMessage('Unknown keys'); $req = array_merge( Data::minimalRequest(), @@ -132,7 +132,7 @@ public function testUnknownKey(): void public function testInvalidChargebackCodes($chargebackCode): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('chargeback_code must be of type string'); + $this->expectExceptionMessage('Expected chargeback_code'); $req = array_merge( Data::minimalRequest(), @@ -152,7 +152,7 @@ public function testInvalidChargebackCodes($chargebackCode): void public function testInvalidNotes($notes): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('notes must be of type string'); + $this->expectExceptionMessage('Expected notes'); $req = array_merge(Data::minimalRequest(), ['notes' => $notes]); $this->createReportTransactionRequest( @@ -169,7 +169,7 @@ public function testInvalidNotes($notes): void public function testInvalidTransactionIds($transactionId): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('transaction_id must be of type string'); + $this->expectExceptionMessage('Expected transaction_id'); $req = array_merge( Data::minimalRequest(), @@ -196,7 +196,7 @@ public static function notStringTypes(): array public function testInvalidIpAddresses(string $ip): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('ip_address must be an IP address'); + $this->expectExceptionMessage('is an invalid IP address'); $req = array_merge( Data::minimalRequest(), @@ -223,7 +223,7 @@ public static function invalidIpAddresses(): array public function testInvalidMaxmindIds(string $maxmindId): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('maxmind_id must have a length of 8'); + $this->expectExceptionMessage('must be 8 characters long'); $req = array_merge( Data::minimalRequest(), @@ -250,7 +250,7 @@ public static function invalidMaxmindIds(): array public function testInvalidMinfraudIds(string $minfraudId): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('minfraud_id must validate against'); + $this->expectExceptionMessage('must be a valid minFraud ID'); $req = array_merge( Data::minimalRequest(), @@ -280,7 +280,7 @@ public static function invalidMinfraudIds(): array public function testInvalidTags(string $tag): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('tag must be in'); + $this->expectExceptionMessage('must be one of'); $req = array_merge(Data::minimalRequest(), ['tag' => $tag]); $this->createReportTransactionRequest( diff --git a/tests/MaxMind/Test/MinFraud/Validation/Rules/CreditCardTest.php b/tests/MaxMind/Test/MinFraud/Validation/Rules/CreditCardTest.php deleted file mode 100644 index 6637666b..00000000 --- a/tests/MaxMind/Test/MinFraud/Validation/Rules/CreditCardTest.php +++ /dev/null @@ -1,66 +0,0 @@ -expectExceptionMessageMatches('/^country must be a valid country|country must be uppercase$/'); - - $validator->check([ - 'country' => $code, - ]); - } - - public static function invalidCountries(): array - { - return [ - ['USA'], - ['Canada'], - [1], - [null], - ['ca'], - ]; - } - - /** - * @dataProvider validCountries - */ - public function testValidCountry(string $code): void - { - $validator = new CreditCard(); - - $this->assertTrue( - $validator->validate([ - 'country' => $code, - ]), - $code, - ); - } - - public static function validCountries(): array - { - return [ - ['US'], - ['CA'], - ]; - } -} diff --git a/tests/MaxMind/Test/MinFraud/Validation/Rules/DeviceTest.php b/tests/MaxMind/Test/MinFraud/Validation/Rules/DeviceTest.php deleted file mode 100644 index 7d425c53..00000000 --- a/tests/MaxMind/Test/MinFraud/Validation/Rules/DeviceTest.php +++ /dev/null @@ -1,39 +0,0 @@ -expectException(IpException::class); - - $validator->check([ - 'ip_address' => '1.2.3', - ]); - } - - public function testMissingIP(): void - { - $validator = new Device(); - - $this->assertTrue( - $validator->validate([ - 'session_age' => 1.2, - ]) - ); - } -} diff --git a/tests/MaxMind/Test/MinFraud/Validation/Rules/EventTest.php b/tests/MaxMind/Test/MinFraud/Validation/Rules/EventTest.php deleted file mode 100644 index a5542373..00000000 --- a/tests/MaxMind/Test/MinFraud/Validation/Rules/EventTest.php +++ /dev/null @@ -1,44 +0,0 @@ -assertTrue( - $validator->validate(['type' => $good]), - $good - ); - } - - public static function eventTypeDataProvider(): array - { - return [ - ['account_creation'], - ['account_login'], - ['email_change'], - ['password_reset'], - ['payout_change'], - ['purchase'], - ['recurring_purchase'], - ['referral'], - ['survey'], - ]; - } -} diff --git a/tests/MaxMind/Test/MinFraudData.php b/tests/MaxMind/Test/MinFraudData.php index af1f27df..181c93c5 100644 --- a/tests/MaxMind/Test/MinFraudData.php +++ b/tests/MaxMind/Test/MinFraudData.php @@ -33,6 +33,19 @@ private static function decodeFile(string $file): array throw new \Exception("getting tests file $file failed!"); } - return json_decode($contents, true); + $a = json_decode($contents, true); + self::recursiveKSort($a); + + return $a; + } + + private static function recursiveKSort(array &$array): void + { + ksort($array); + foreach ($array as &$value) { + if (\is_array($value)) { + self::recursiveKSort($value); + } + } } } diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index 9ec89ccd..307f47a6 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -36,20 +36,20 @@ public function testFullRequest(string $class, string $service): void public function testFullInsightsRequestBuiltPiecemeal(string $class, string $service): void { $incompleteMf = $this->createMinFraudRequestWithFullResponse($service) - ->withEvent(Data::fullRequest()['event']) ->withAccount(Data::fullRequest()['account']) - ->withEmail(Data::fullRequest()['email']) ->withBilling(Data::fullRequest()['billing']) - ->withShipping(Data::fullRequest()['shipping']) - ->withPayment(Data::fullRequest()['payment']) ->withCreditCard(Data::fullRequest()['credit_card']) ->withCustomInputs(Data::fullRequest()['custom_inputs']) + ->withDevice(Data::fullRequest()['device']) + ->withEmail(Data::fullRequest()['email']) + ->withEvent(Data::fullRequest()['event']) ->withOrder(Data::fullRequest()['order']) + ->withPayment(Data::fullRequest()['payment']) + ->withShipping(Data::fullRequest()['shipping']) ->withShoppingCartItem(Data::fullRequest()['shopping_cart'][0]); $mf = $incompleteMf - ->withShoppingCartItem(Data::fullRequest()['shopping_cart'][1]) - ->withDevice(Data::fullRequest()['device']); + ->withShoppingCartItem(Data::fullRequest()['shopping_cart'][1]); $responseMeth = $service . 'FullResponse'; $this->assertEquals( @@ -65,6 +65,114 @@ public function testFullInsightsRequestBuiltPiecemeal(string $class, string $ser ); } + /** + * @dataProvider services + */ + public function testFullInsightsRequestUsingNamedArgs(string $class, string $service): void + { + $mf = $this->createMinFraudRequestWithFullResponse($service) + ->withAccount( + userId: '3132', + usernameMd5: '570a90bfbf8c7eab5dc5d4e26832d5b1' + ) + ->withBilling( + firstName: 'First', + lastName: 'Last', + company: 'Company', + address: '101 Address Rd.', + address2: 'Unit 5', + city: 'City of Thorns', + region: 'CT', + country: 'US', + postal: '06510', + phoneNumber: '123-456-7890', + phoneCountryCode: '1' + ) + ->withCreditCard( + country: 'US', + issuerIdNumber: '411111', + lastDigits: '7643', + bankName: 'Bank of No Hope', + bankPhoneCountryCode: '1', + bankPhoneNumber: '123-456-1234', + avsResult: 'Y', + cvvResult: 'N', + token: '123456abc1234', + was3dSecureSuccessful: true + ) + ->withCustomInputs([ + 'boolean_input' => true, + 'float_input' => 12.1, + 'integer_input' => 3123, + 'string_input' => 'This is a string input.', + ]) + ->withDevice( + acceptLanguage: 'en-US,en;q=0.8', + ipAddress: '152.216.7.110', + sessionAge: 3600.5, + sessionId: 'foobar', + userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.89 Safari/537.36', + ) + ->withEmail( + address: '977577b140bfb7c516e4746204fbdb01', + domain: 'maxmind.com' + ) + ->withEvent( + transactionId: 'txn3134133', + shopId: 's2123', + time: '2014-04-12T23:20:50+00:00', + type: 'purchase' + ) + ->withOrder( + amount: 323.21, + currency: 'USD', + discountCode: 'FIRST', + affiliateId: 'af12', + subaffiliateId: 'saf42', + isGift: true, + hasGiftMessage: false, + referrerUri: 'http://www.amazon.com/' + ) + ->withPayment( + processor: 'stripe', + wasAuthorized: false, + declineCode: 'invalid number' + ) + ->withShipping( + firstName: 'ShipFirst', + lastName: 'ShipLast', + company: 'ShipCo', + address: '322 Ship Addr. Ln.', + address2: 'St. 43', + city: 'Nowhere', + region: 'OK', + country: 'US', + postal: '73003', + phoneNumber: '123-456-0000', + phoneCountryCode: '1', + deliverySpeed: 'same_day' + ) + ->withShoppingCartItem( + category: 'pets', + itemId: 'ad23232', + quantity: 2, + price: 20.43 + ) + ->withShoppingCartItem( + category: 'beauty', + itemId: 'bst112', + quantity: 1, + price: 100.0 + ); + + $responseMeth = $service . 'FullResponse'; + $this->assertEquals( + new $class(Data::$responseMeth()), + $mf->{$service}(), + 'response for full request built piece by piece' + ); + } + public function testLocalesOption(): void { $insights = $this->createMinFraudRequestWithFullResponse( @@ -163,11 +271,11 @@ public function testRequestsWithNulls(): void { $insights = $this->createNullRequest() ->with([ - 'device' => ['ip_address' => '1.1.1.1'], 'billing' => [ 'first_name' => 'firstname', 'last_name' => null, ], + 'device' => ['ip_address' => '1.1.1.1'], 'shopping_cart' => [ [ 'category' => 'catname', @@ -186,11 +294,11 @@ public function testRequestsWithNulls(): void public function testRequestsWithNullsPiecemeal(): void { $insights = $this->createNullRequest() - ->withDevice(['ip_address' => '1.1.1.1']) ->withBilling([ 'first_name' => 'firstname', 'last_name' => null, ]) + ->withDevice(['ip_address' => '1.1.1.1']) ->withShoppingCartItem([ 'category' => 'catname', 'item_id' => null, @@ -211,8 +319,8 @@ private function createNullRequest(): MinFraud 1, [], [ - 'device' => ['ip_address' => '1.1.1.1'], 'billing' => ['first_name' => 'firstname'], + 'device' => ['ip_address' => '1.1.1.1'], 'shopping_cart' => [['category' => 'catname']], ] ); @@ -278,7 +386,7 @@ public function testMissingIpAddressWithoutValidation(string $class, string $ser public function testUnknownKeys(string $method): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('Must not have keys'); + $this->expectExceptionMessage('Unknown keys'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -321,7 +429,7 @@ public function testAccountWithBadUsernameMd5(string $md5): void public function testEmailWithBadAddress(string $md5): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must be an MD5'); + $this->expectExceptionMessage('is an invalid email address'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -345,7 +453,7 @@ public static function badMd5s(): array public function testBadRegions(string $method, string $region): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must be an ISO 3166-2'); + $this->expectExceptionMessage('valid ISO 3166-2 region code'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -363,13 +471,34 @@ public static function badRegions(): array ]; } + /** + * @dataProvider goodCountryCodes + */ + public function testGoodCountryCode(string $method, string $code): void + { + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->{$method}(['country' => $code]); + } + + public static function goodCountryCodes(): array + { + return self::generateTestData( + ['withBilling', 'withCreditCard', 'withShipping'], + ['CA', 'US'], + ); + } + /** * @dataProvider badCountryCodes + * + * @param mixed $code */ - public function testBadCountryCode(string $method, string $code): void + public function testBadCountryCode(string $method, $code): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must be a valid country'); + $this->expectExceptionMessageMatches('/Expected country|valid ISO 3166-1 country code/'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -379,14 +508,30 @@ public function testBadCountryCode(string $method, string $code): void public static function badCountryCodes(): array { - return [ - ['withBilling', 'A'], - ['withBilling', '1'], - ['withBilling', 'MAA'], - ['withShipping', 'A'], - ['withShipping', 'MAA'], - ['withShipping', '1'], - ]; + return self::generateTestData( + ['withBilling', 'withCreditCard', 'withShipping'], + [ + 'A', + '1', + 'MAA', + 'USA', + 'Canada', + 1, + 'ca', + ] + ); + } + + private static function generateTestData(array $methods, array $values): array + { + $tests = []; + foreach ($methods as $method) { + foreach ($values as $value) { + $tests[] = [$method, $value]; + } + } + + return $tests; } /** @@ -395,7 +540,7 @@ public static function badCountryCodes(): array public function testBadPhoneCodes(string $method, string $key, string $code): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must be a valid telephone country code'); + $this->expectExceptionMessage('must be a string of 1 to 4 digits'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -418,7 +563,7 @@ public static function badPhoneCodes(): array public function testBadDeliverySpeed(): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('delivery_speed must be in'); + $this->expectExceptionMessage('valid delivery speed'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -432,7 +577,7 @@ public function testBadDeliverySpeed(): void public function testBadIin(string $iin): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must validate against'); + $this->expectExceptionMessage('string of 6 or 8 digits'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -455,7 +600,7 @@ public static function badIins(): array public function testCreditCardWithBadLastDigits(string $lastDigits): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must validate against'); + $this->expectExceptionMessage('string of 2 or 4 digits'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -494,7 +639,7 @@ public function testCreditCardDeprecatedLast4Digits(): void public function testCreditCardWithNumericToken(string $token): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must not validate against'); + $this->expectExceptionMessage('card number'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -516,7 +661,7 @@ public static function numericToken(): array public function testCreditCardWithInvalidRangeToken(string $token): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must validate against'); + $this->expectExceptionMessage('string of 1 to 255 printable ASCII characters'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -538,7 +683,7 @@ public static function invalidRangeToken(): array public function testCreditCardWithLongToken(string $token): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must validate against'); + $this->expectExceptionMessage('string of 1 to 255 printable ASCII characters'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -579,7 +724,7 @@ public static function goodToken(): array public function testAvsAndCCv(string $key): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must have a length'); + $this->expectExceptionMessage('must be a string of length 1'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -601,7 +746,7 @@ public static function avsAndCvv(): array public function testBadIps(string $ip): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must be an IP address'); + $this->expectExceptionMessage('is an invalid IP address'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -612,6 +757,7 @@ public function testBadIps(string $ip): void public static function badIps(): array { return [ + ['1.2.3'], ['1.2.3.'], ['299.1.1.1'], ['::AF123'], @@ -645,12 +791,12 @@ public static function negativeSessionAge(): array public function testBadSessionAge(string $age): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must be a float number'); + $this->expectExceptionMessage('Expected session_age'); $this->createMinFraudRequestWithFullResponse( 'insights', 0 - )->withDevice(['ip_address' => '1.2.3.4', 'session_age' => $age]); + )->withDevice(['session_age' => $age]); } public static function badSessionAge(): array @@ -670,7 +816,7 @@ public function testGoodSessionAge($age): void $this->createMinFraudRequestWithFullResponse( 'insights', 0 - )->withDevice(['ip_address' => '1.2.3.4', 'session_age' => $age]); + )->withDevice(['session_age' => $age]); } public static function goodSessionAge(): array @@ -689,7 +835,7 @@ public static function goodSessionAge(): array public function testBadSessionId(string $id): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must have a length between 1 and 255'); + $this->expectExceptionMessage('must be a string with length between 1 and 255'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -749,7 +895,7 @@ public static function goodIps(): array public function testBadDomains(string $domain): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must'); + $this->expectExceptionMessage('valid domain name'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -817,7 +963,7 @@ public static function goodTimes(): array public function testBadEventTime(): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must be a valid date'); + $this->expectExceptionMessage('valid RFC 3339'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -825,10 +971,36 @@ public function testBadEventTime(): void )->withEvent(['time' => '2014/04/04 19:20']); } + /** + * @dataProvider goodEventTypes + */ + public function testGoodEventType(string $good): void + { + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withEvent(['type' => $good]); + } + + public static function goodEventTypes(): array + { + return [ + ['account_creation'], + ['account_login'], + ['email_change'], + ['password_reset'], + ['payout_change'], + ['purchase'], + ['recurring_purchase'], + ['referral'], + ['survey'], + ]; + } + public function testBadEventType(): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must be'); + $this->expectExceptionMessage('valid event type'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -842,7 +1014,7 @@ public function testBadEventType(): void public function testBadCurrency(string $currency): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must validate against'); + $this->expectExceptionMessage('valid currency code'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -866,7 +1038,7 @@ public static function badCurrency(): array public function testBadReferrerUri(string $uri): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessageMatches('/must be an? URL/'); + $this->expectExceptionMessageMatches('/valid URL/'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -885,7 +1057,7 @@ public static function badReferrerUri(): array public function testBadPaymentProcessor(): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('must be'); + $this->expectExceptionMessage('valid payment processor'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -933,7 +1105,7 @@ public static function validAmounts(): array public function testBadOrderAmount($value): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessageMatches('/(must be greater than or equal to 0|must be a float)/'); + $this->expectExceptionMessageMatches('/Expected amount|must be greater than or equal to 0/'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -958,7 +1130,7 @@ public static function invalidAmounts(): array public function testBadShoppingCartItemPrice($value): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessageMatches('/(must be greater than or equal to 0|must be a float)/'); + $this->expectExceptionMessageMatches('/Expected price|must be greater than or equal to 0/'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -974,7 +1146,7 @@ public function testBadShoppingCartItemPrice($value): void public function testBadShoppingCartItemQuantity($value): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessageMatches('/(must be greater than 0|must be an int)/'); + $this->expectExceptionMessageMatches('/Expected quantity|must be greater than or equal to 0/'); $this->createMinFraudRequestWithFullResponse( 'insights', @@ -996,7 +1168,7 @@ public static function invalidQuantities(): array public function testBadShoppingCartItemWithDoubleArray(): void { $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('Must not have keys'); + $this->expectExceptionMessage('Unknown keys'); $this->createMinFraudRequestWithFullResponse( 'insights', diff --git a/tests/data/minfraud/reporttransaction/full-request.json b/tests/data/minfraud/reporttransaction/full-request.json index 73986eac..b595a1ef 100644 --- a/tests/data/minfraud/reporttransaction/full-request.json +++ b/tests/data/minfraud/reporttransaction/full-request.json @@ -1,9 +1,9 @@ { - "ip_address": "152.216.7.110", - "tag": "chargeback", - "chargeback_code": "UA01 Fraud - Card Present Transaction", - "minfraud_id": "c8ce89f9-734d-4411-a174-91560f5ec07a", - "maxmind_id": "a1b2c3d4", - "notes": "Fraudster was wearing a clown outfit.", - "transaction_id": "txn3134133" -} + "chargeback_code": "UA01 Fraud - Card Present Transaction", + "ip_address": "152.216.7.110", + "maxmind_id": "a1b2c3d4", + "minfraud_id": "c8ce89f9-734d-4411-a174-91560f5ec07a", + "notes": "Fraudster was wearing a clown outfit.", + "tag": "chargeback", + "transaction_id": "txn3134133" +} \ No newline at end of file