[ECP-9941] Fix L2/L3 enhanced scheme data validation#3307
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a new Level23DataValidator class to sanitize and validate Level 2/3 transaction data, refactoring AdditionalDataLevel23DataBuilder to delegate validation tasks to this new class. It also adds comprehensive unit tests for the validator. The code review feedback highlights several critical issues: a potential fatal error due to a missing null check on the shipping address, a regression where casting quantities to integers truncates fractional values, a regex bug that rejects non-ASCII product descriptions, and an opportunity to improve sanitization by using trim() instead of ltrim().
| $postalCode = $this->level23DataValidator->sanitizePostalCode( | ||
| (string) $order->getShippingAddress()->getPostcode() | ||
| ); | ||
| if ($postalCode !== null) { | ||
| $additionalDataLevel23[self::ENHANCED_SCHEME_DATA_PREFIX . '.destinationPostalCode'] = | ||
| $postalCode; | ||
| } | ||
|
|
||
| $additionalDataLevel23[self::ENHANCED_SCHEME_DATA_PREFIX . '.destinationCountryCode'] = | ||
| $order->getShippingAddress()->getCountryId(); | ||
| $alpha3CountryCode = $this->level23DataValidator->convertCountryCodeToAlpha3( | ||
| (string) $order->getShippingAddress()->getCountryId() | ||
| ); | ||
| if ($alpha3CountryCode !== null) { | ||
| $additionalDataLevel23[self::ENHANCED_SCHEME_DATA_PREFIX . '.destinationCountryCode'] = | ||
| $alpha3CountryCode; | ||
| } | ||
|
|
||
| if (!empty($order->getShippingAddress()->getRegionCode())) { | ||
| $additionalDataLevel23[self::ENHANCED_SCHEME_DATA_PREFIX . '.destinationStateProvinceCode'] = | ||
| $order->getShippingAddress()->getRegionCode(); | ||
| $regionCode = $order->getShippingAddress()->getRegionCode(); | ||
| if (!empty($regionCode)) { | ||
| $sanitizedRegionCode = $this->level23DataValidator->sanitizeStateProvinceCode( | ||
| (string) $regionCode | ||
| ); | ||
| if ($sanitizedRegionCode !== null) { | ||
| $additionalDataLevel23[self::ENHANCED_SCHEME_DATA_PREFIX . '.destinationStateProvinceCode'] = | ||
| $sanitizedRegionCode; | ||
| } | ||
| } |
There was a problem hiding this comment.
The $order->getShippingAddress() method can return null (for example, if the shipping address is missing or deleted). Calling methods like getPostcode(), getCountryId(), or getRegionCode() directly on a potentially null object will trigger a fatal error.
Please add a null check for the shipping address before accessing its properties.
$shippingAddress = $order->getShippingAddress();
if ($shippingAddress) {
$postalCode = $this->level23DataValidator->sanitizePostalCode(
(string) $shippingAddress->getPostcode()
);
if ($postalCode !== null) {
$additionalDataLevel23[self::ENHANCED_SCHEME_DATA_PREFIX . '.destinationPostalCode'] =
$postalCode;
}
$alpha3CountryCode = $this->level23DataValidator->convertCountryCodeToAlpha3(
(string) $shippingAddress->getCountryId()
);
if ($alpha3CountryCode !== null) {
$additionalDataLevel23[self::ENHANCED_SCHEME_DATA_PREFIX . '.destinationCountryCode'] =
$alpha3CountryCode;
}
$regionCode = $shippingAddress->getRegionCode();
if (!empty($regionCode)) {
$sanitizedRegionCode = $this->level23DataValidator->sanitizeStateProvinceCode(
(string) $regionCode
);
if ($sanitizedRegionCode !== null) {
$additionalDataLevel23[self::ENHANCED_SCHEME_DATA_PREFIX . '.destinationStateProvinceCode'] =
$sanitizedRegionCode;
}
}
}| // `totalAmount` should be a non-zero numeric value. | ||
| if ($orderItem->getRowTotal() === 0) { | ||
| $validationResult = false; | ||
| $quantity = (int) $item->getQtyOrdered(); |
There was a problem hiding this comment.
Casting $item->getQtyOrdered() to (int) truncates fractional quantities (e.g., 1.5 becomes 1). This is a regression from the original implementation which preserved fractional quantities as strings. It will lead to incorrect total amount calculations and mismatched order data sent to Adyen.
Please cast the quantity to (float) instead of (int).
$quantity = (float) $item->getQtyOrdered();| public function calculateLineItemTotalAmount(int $quantity, int $unitPrice, int $discountAmount): int | ||
| { | ||
| return ($quantity * $unitPrice) - $discountAmount; | ||
| } |
There was a problem hiding this comment.
To support fractional quantities correctly, the $quantity parameter should be typed as float instead of int. The calculation should also round the resulting total to the nearest minor unit (integer) before returning.
public function calculateLineItemTotalAmount(float $quantity, int $unitPrice, int $discountAmount): int
{
return (int) round(($quantity * $unitPrice) - $discountAmount);
}| private function isAllSpecialCharacters(string $value): bool | ||
| { | ||
| return $value !== '' && preg_match('/^[^a-zA-Z0-9]+$/', $value) === 1; | ||
| } |
There was a problem hiding this comment.
The regex /^[^a-zA-Z0-9]+$/ matches any string consisting entirely of non-ASCII alphanumeric characters. This means purely non-ASCII product descriptions (e.g., in Chinese, Japanese, Cyrillic, Arabic, etc.) will be incorrectly identified as consisting entirely of "special characters" and rejected.
To support internationalization and multibyte characters, use the u (UTF-8) modifier along with Unicode character properties \p{L} (letters) and \p{N} (numbers).
private function isAllSpecialCharacters(string $value):
{
return $value !== '' && preg_match('/^[\p{L}\p{N}]+$/u', $value) === 1;
}| private function trimAndTruncate(string $value, int $maxLength): string | ||
| { | ||
| $value = ltrim($value); | ||
| return mb_substr($value, 0, $maxLength); | ||
| } |
There was a problem hiding this comment.
Using ltrim only removes leading spaces, leaving trailing spaces intact. Trailing spaces are generally undesirable in sanitized fields (like postal codes, product codes, or customer references) and can cause validation issues or waste character limits.
Please use trim() instead of ltrim() to clean up both ends of the string.
private function trimAndTruncate(string $value, int $maxLength): string
{
$value = trim($value);
return mb_substr($value, 0, $maxLength);
}| public function testCalculateLineItemTotalAmount( | ||
| int $quantity, | ||
| int $unitPrice, | ||
| int $discountAmount, | ||
| int $expected | ||
| ): void { | ||
| $result = $this->validator->calculateLineItemTotalAmount($quantity, $unitPrice, $discountAmount); | ||
| $this->assertSame($expected, $result); | ||
| } |
There was a problem hiding this comment.
Update the parameter type of $quantity to float to match the updated signature of calculateLineItemTotalAmount.
public function testCalculateLineItemTotalAmount(
float $quantity,
int $unitPrice,
int $discountAmount,
int $expected
): void {
$result = $this->validator->calculateLineItemTotalAmount($quantity, $unitPrice, $discountAmount);
$this->assertSame($expected, $result);
}
|



Description
This PR fixes L2/L3 enhanced scheme data validation errors by introducing a dedicated validator.