PluginProbe
MultiSafepay plugin for WooCommerce / trunk
MultiSafepay plugin for WooCommerce vtrunk
6.11.1 6.12.0 6.13.0 6.2.0 6.2.1 6.3.0 6.3.1 6.4.0 6.4.1 6.4.2 6.4.3 6.5.0 6.5.1 6.6.0 6.6.1 6.6.2 6.7.0 6.7.1 6.7.2 6.7.3 6.8.0 6.8.1 6.8.2 6.8.3 6.9.0 All 84 releases
multisafepay / src / Services / CustomerService.php

CustomerService.php in MultiSafepay plugin for WooCommerce trunk, at src/Services/CustomerService.php

541 lines 17.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php declare(strict_types=1);
2
3 namespace MultiSafepay\WooCommerce\Services;
4
5 use MultiSafepay\Api\Transactions\OrderRequest\Arguments\CustomerDetails;
6 use MultiSafepay\Exception\InvalidArgumentException;
7 use MultiSafepay\ValueObject\Customer\Address;
8 use MultiSafepay\ValueObject\Customer\AddressParser;
9 use MultiSafepay\ValueObject\Customer\Country;
10 use MultiSafepay\ValueObject\Customer\EmailAddress;
11 use MultiSafepay\ValueObject\Customer\PhoneNumber;
12 use MultiSafepay\ValueObject\IpAddress;
13 use MultiSafepay\WooCommerce\Utils\Logger;
14 use MultiSafepay\WooCommerce\Services\Blocks\BlocksContextService;
15 use WC_Order;
16
17 /**
18 * Class CustomerService
19 *
20 * @package MultiSafepay\WooCommerce\Services
21 */
22 class CustomerService {
23 public const DEFAULT_LOCALE = 'en_US';
24
25 /**
26 * @var Logger
27 */
28 private $logger;
29
30 /**
31 * @var BlocksPaymentDataService
32 */
33 private $blocks_payment_data_service;
34
35 /**
36 * @param Logger|null $logger
37 */
38 public function __construct( ?Logger $logger = null ) {
39 $this->logger = $logger ?? new Logger();
40 $this->blocks_payment_data_service = new BlocksPaymentDataService();
41 }
42
43 /**
44 * Browser info is sent as a JSON string from the frontend.
45 * Treat it as opaque: do not sanitize as plain text.
46 *
47 * @param string $value
48 * @return string
49 */
50 private function normalize_browser_payload( string $value ): string {
51 $value = trim( $value );
52 if ( '' === $value ) {
53 return '';
54 }
55
56 $value = str_replace( "\0", '', $value );
57
58 $decoded = json_decode( $value, true );
59 if ( ! is_array( $decoded ) || ! isset( $decoded['browser'] ) || ! is_array( $decoded['browser'] ) ) {
60 return '';
61 }
62
63 $normalized_browser = $this->normalize_browser_fields( $decoded['browser'] );
64 if ( empty( $normalized_browser ) ) {
65 return '';
66 }
67
68 $normalized_value = wp_json_encode(
69 array(
70 'browser' => $normalized_browser,
71 )
72 );
73
74 return is_string( $normalized_value ) ? $normalized_value : '';
75 }
76
77 /**
78 * Normalize expected browser fields to a stable and bounded payload.
79 *
80 * @param array $browser
81 * @return array
82 */
83 private function normalize_browser_fields( array $browser ): array {
84 $boolean_fields = array( 'javascript_enabled', 'java_enabled', 'cookies_enabled' );
85 $normalized_browser = array();
86 $field_rules = array_fill_keys(
87 $boolean_fields,
88 array( 'type' => 'bool' )
89 ) + array(
90 'language' => array(
91 'type' => 'string',
92 'max_length' => 35,
93 ),
94 'screen_color_depth' => array(
95 'type' => 'int',
96 'allowed_values' => array( 1, 4, 8, 15, 16, 24, 32, 48 ),
97 ),
98 'screen_width' => array(
99 'type' => 'int',
100 'min_value' => 0,
101 ),
102 'screen_height' => array(
103 'type' => 'int',
104 'min_value' => 0,
105 ),
106 'time_zone' => array(
107 'type' => 'int',
108 'min_value' => -1440,
109 'max_value' => 1440,
110 ),
111 'user_agent' => array(
112 'type' => 'string',
113 'max_length' => 512,
114 ),
115 'platform' => array(
116 'type' => 'string',
117 'max_length' => 128,
118 ),
119 );
120
121 foreach ( $field_rules as $field => $rule ) {
122 if ( ! array_key_exists( $field, $browser ) ) {
123 continue;
124 }
125
126 $normalized_value = $this->normalize_browser_field_by_rule( $browser[ $field ], $rule );
127 if ( null === $normalized_value ) {
128 continue;
129 }
130
131 $normalized_browser[ $field ] = $normalized_value;
132 }
133
134 return $normalized_browser;
135 }
136
137 /**
138 * Normalize a browser field according to its configured rule.
139 *
140 * @param mixed $value
141 * @param array $rule
142 * @return bool|int|string|null
143 */
144 private function normalize_browser_field_by_rule( $value, array $rule ) {
145 if ( 'bool' === $rule['type'] ) {
146 return $this->normalize_browser_boolean_field( $value );
147 }
148
149 if ( 'int' === $rule['type'] ) {
150 return $this->normalize_browser_integer_field( $value, $rule );
151 }
152
153 return $this->normalize_browser_string_field( $value, $rule['max_length'] );
154 }
155
156 /**
157 * Normalize a scalar browser field into a trimmed string without truncation.
158 *
159 * @param mixed $value
160 * @return string
161 */
162 private function normalize_browser_scalar_string_value( $value ): string {
163 if ( is_bool( $value ) ) {
164 $value = $value ? 'true' : 'false';
165 }
166
167 if ( is_int( $value ) || is_float( $value ) ) {
168 $value = (string) $value;
169 }
170
171 if ( ! is_string( $value ) ) {
172 return '';
173 }
174
175 return trim( str_replace( "\0", '', $value ) );
176 }
177
178 /**
179 * Normalize a browser string field by trimming, removing null bytes, and truncating.
180 *
181 * @param mixed $value
182 * @param int $max_length
183 * @return string
184 */
185 private function normalize_browser_string_field( $value, int $max_length ): string {
186 $value = $this->normalize_browser_scalar_string_value( $value );
187 if ( '' === $value ) {
188 return '';
189 }
190
191 return strlen( $value ) > $max_length ? substr( $value, 0, $max_length ) : $value;
192 }
193
194 /**
195 * Normalize a browser boolean field from scalar input.
196 *
197 * @param mixed $value
198 * @return bool
199 */
200 private function normalize_browser_boolean_field( $value ): bool {
201 if ( is_bool( $value ) ) {
202 return $value;
203 }
204
205 $value = strtolower( $this->normalize_browser_scalar_string_value( $value ) );
206 return in_array( $value, array( '1', 'true' ), true );
207 }
208
209 /**
210 * Normalize a browser integer field from scalar input.
211 *
212 * @param mixed $value
213 * @param array $rule
214 * @return int|null
215 */
216 private function normalize_browser_integer_field( $value, array $rule ): ?int {
217 $normalized_value = is_int( $value ) ? $value : null;
218
219 if ( null === $normalized_value ) {
220 $value = $this->normalize_browser_scalar_string_value( $value );
221 if ( '' === $value || ! preg_match( '/^-?\d+$/', $value ) ) {
222 return 0;
223 }
224
225 $normalized_value = (int) $value;
226 }
227
228 if ( isset( $rule['allowed_values'] ) && ! in_array( $normalized_value, $rule['allowed_values'], true ) ) {
229 return null;
230 }
231
232 if ( isset( $rule['min_value'] ) ) {
233 $normalized_value = max( $rule['min_value'], $normalized_value );
234 }
235
236 if ( isset( $rule['max_value'] ) ) {
237 $normalized_value = min( $rule['max_value'], $normalized_value );
238 }
239
240 return $normalized_value;
241 }
242
243 /**
244 * @param WC_Order $order
245 * @return CustomerDetails
246 * @throws InvalidArgumentException
247 */
248 public function create_customer_details( WC_Order $order ): CustomerDetails {
249 $customer_address = $this->create_address(
250 $order->get_billing_address_1(),
251 $order->get_billing_address_2(),
252 $order->get_billing_country(),
253 $order->get_billing_state(),
254 $order->get_billing_city(),
255 $order->get_billing_postcode()
256 );
257
258 return $this->create_customer(
259 $customer_address,
260 $order->get_billing_email(),
261 $order->get_billing_phone(),
262 $order->get_billing_first_name(),
263 $order->get_billing_last_name(),
264 $order->get_customer_ip_address() ? $order->get_customer_ip_address() : '',
265 $order->get_customer_user_agent() ? $order->get_customer_user_agent() : '',
266 $order->get_billing_company(),
267 $this->should_send_customer_reference( $order ) ? (string) $order->get_customer_id() : null,
268 $this->get_customer_browser_info( $order )
269 );
270 }
271
272 /**
273 * Return browser information
274 *
275 * @param WC_Order|null $order
276 * @return array|null
277 */
278 protected function get_customer_browser_info( ?WC_Order $order = null ): ?array {
279 $browser = ( new BlocksContextService() )->is_store_api_request()
280 ? $this->get_blocks_checkout_browser_payload( $order )
281 : $this->get_classic_checkout_browser_payload();
282
283 return $this->decode_browser_payload( $browser );
284 }
285
286 /**
287 * Read browser information from classic checkout POST data.
288 *
289 * @return string
290 */
291 private function get_classic_checkout_browser_payload(): string {
292 return $this->normalize_browser_payload( $this->get_request_browser_payload( 'browser', false ) );
293 }
294
295 /**
296 * Read browser information from Blocks request- / meta-sources.
297 *
298 * @param WC_Order|null $order
299 * @return string
300 */
301 private function get_blocks_checkout_browser_payload( ?WC_Order $order = null ): string {
302 $browser = $this->normalize_browser_payload( $this->get_request_browser_payload( 'browser', true ) );
303
304 if ( ! empty( $browser ) || ! $order instanceof WC_Order ) {
305 return $browser;
306 }
307
308 $payment_method_id = (string) $order->get_payment_method();
309 $payment_method_key = $payment_method_id ? $payment_method_id . '_browser' : '';
310
311 if ( ! empty( $payment_method_key ) ) {
312 $browser = $this->normalize_browser_payload( $this->get_request_browser_payload( $payment_method_key, true ) );
313
314 if ( ! empty( $browser ) ) {
315 return $browser;
316 }
317
318 $browser = $this->normalize_browser_payload( $this->blocks_payment_data_service->get_blocks_payment_data_value( $order, $payment_method_key ) );
319
320 if ( ! empty( $browser ) ) {
321 return $browser;
322 }
323 }
324
325 return $this->normalize_browser_payload( $this->blocks_payment_data_service->get_blocks_payment_data_value( $order, 'browser' ) );
326 }
327
328 /**
329 * Read the raw browser JSON payload from the request.
330 *
331 * @param string $key
332 * @param bool $is_store_api_request
333 * @return string
334 */
335 private function get_request_browser_payload( string $key, bool $is_store_api_request ): string {
336 if ( ! isset( $_POST[ $key ] ) ) {
337 return '';
338 }
339
340 // Treat browser payloads as opaque JSON: do not sanitize/alter them, only decode later.
341 // Store API requests are already unslashed by the REST layer.
342 // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
343 if ( $is_store_api_request ) {
344 return (string) $_POST[ $key ]; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
345 }
346
347 return (string) wp_unslash( $_POST[ $key ] );
348 // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
349 }
350
351 /**
352 * Decode browser JSON into the array structure expected by the SDK.
353 *
354 * @param string $browser
355 * @return array|null
356 */
357 private function decode_browser_payload( string $browser ): ?array {
358 if ( '' === $browser ) {
359 return null;
360 }
361
362 $decoded = json_decode( $browser, true );
363 return is_array( $decoded ) ? $decoded : null;
364 }
365
366 /**
367 * @param WC_Order $order
368 * @return CustomerDetails
369 * @throws InvalidArgumentException
370 */
371 public function create_delivery_details( WC_Order $order ): CustomerDetails {
372 $delivery_address = $this->create_address(
373 $order->get_shipping_address_1(),
374 $order->get_shipping_address_2(),
375 $order->get_shipping_country(),
376 $order->get_shipping_state(),
377 $order->get_shipping_city(),
378 $order->get_shipping_postcode()
379 );
380
381 return $this->create_customer(
382 $delivery_address,
383 $order->get_billing_email(),
384 $order->get_billing_phone(),
385 $order->get_shipping_first_name(),
386 $order->get_shipping_last_name(),
387 '',
388 '',
389 $order->get_shipping_company()
390 );
391 }
392
393 /**
394 * @param Address $address
395 * @param string $email_address
396 * @param string $phone_number
397 * @param string $first_name
398 * @param string $last_name
399 * @param string $ip_address
400 * @param string $user_agent
401 * @param null|string $company_name
402 * @param null|string $customer_id
403 * @param null|array $browser
404 * @return CustomerDetails
405 * @throws InvalidArgumentException
406 */
407 protected function create_customer(
408 Address $address,
409 string $email_address,
410 string $phone_number,
411 string $first_name,
412 string $last_name,
413 string $ip_address,
414 string $user_agent,
415 ?string $company_name = null,
416 ?string $customer_id = null,
417 ?array $browser = null
418 ): CustomerDetails {
419 $customer_details = new CustomerDetails();
420 $customer_details
421 ->addAddress( $address )
422 ->addEmailAddress( new EmailAddress( $email_address ) )
423 ->addFirstName( $first_name )
424 ->addLastName( $last_name )
425 ->addPhoneNumber( new PhoneNumber( $phone_number ) )
426 ->addLocale( $this->get_locale() )
427 ->addCompanyName( $company_name ?? '' );
428
429 if ( ! empty( $ip_address ) ) {
430 try {
431 $customer_details->addIpAddress( new IpAddress( $ip_address ) );
432 } catch ( InvalidArgumentException $invalid_argument_exception ) {
433 $this->logger->log_warning( 'Invalid Customer IP address: ' . $invalid_argument_exception->getMessage() );
434 }
435 }
436
437 if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
438 try {
439 $customer_details->addForwardedIp( new IpAddress( sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) ) );
440 } catch ( InvalidArgumentException $invalid_argument_exception ) {
441 $this->logger->log_warning( 'Invalid Forwarded IP address: ' . $invalid_argument_exception->getMessage() );
442 }
443 }
444
445 if ( ! empty( $user_agent ) ) {
446 $customer_details->addUserAgent( $user_agent );
447 }
448
449 if ( ! empty( $customer_id ) ) {
450 $customer_details->addReference( $customer_id );
451 }
452
453 if ( ! empty( $browser ) ) {
454 $customer_details->addData( $browser );
455 }
456
457 return $customer_details;
458 }
459
460 /**
461 * @param string $address_line_1
462 * @param string $address_line_2
463 * @param string $country
464 * @param string $state
465 * @param string $city
466 * @param string $zip_code
467 * @return Address
468 * @throws InvalidArgumentException
469 */
470 protected function create_address(
471 string $address_line_1,
472 string $address_line_2,
473 string $country,
474 string $state,
475 string $city,
476 string $zip_code
477 ): Address {
478 $address_parser = new AddressParser();
479 $address = $address_parser->parse( $address_line_1, $address_line_2 );
480
481 $street = $address[0];
482 $house_number = $address[1];
483
484 $customer_address = new Address();
485 return $customer_address
486 ->addStreetName( $street )
487 ->addHouseNumber( $house_number )
488 ->addState( $state )
489 ->addCity( $city )
490 ->addCountry( new Country( $country ) )
491 ->addZipCode( $zip_code );
492 }
493
494 /**
495 * Return customer locale
496 *
497 * @return string
498 */
499 public function get_locale(): string {
500 $locale = get_locale() ?? self::DEFAULT_LOCALE;
501 return apply_filters( 'multisafepay_customer_locale', $locale );
502 }
503
504 /**
505 * Normalize tokenize flags from checkout payloads.
506 *
507 * @param mixed $value
508 * @return bool|null Null means the value is not a recognized boolean representation.
509 */
510 private function normalize_tokenize_flag( $value ): ?bool {
511 $normalized_value = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
512 return is_bool( $normalized_value ) ? $normalized_value : null;
513 }
514
515 /**
516 * Customer reference only needs to be sent when a payment token is being used or
517 * when payment tokens need to be created.
518 *
519 * @param WC_Order $order
520 * @return bool
521 */
522 protected function should_send_customer_reference( WC_Order $order ): bool {
523 $payment_method_id = $order->get_payment_method();
524 $tokenize_field_key = $payment_method_id . '_payment_component_tokenize';
525
526 $tokenize_from_blocks = trim( $this->blocks_payment_data_service->get_blocks_payment_data_value( $order, $tokenize_field_key ) );
527 if ( '' !== $tokenize_from_blocks ) {
528 $normalized_tokenize_from_blocks = $this->normalize_tokenize_flag( $tokenize_from_blocks );
529 return null === $normalized_tokenize_from_blocks ? false : $normalized_tokenize_from_blocks;
530 }
531
532 if ( ! isset( $_POST[ $tokenize_field_key ] ) ) {
533 return false;
534 }
535
536 $tokenize_from_post = sanitize_text_field( wp_unslash( $_POST[ $tokenize_field_key ] ) );
537 $normalized_tokenize_from_post = $this->normalize_tokenize_flag( $tokenize_from_post );
538 return null === $normalized_tokenize_from_post ? false : $normalized_tokenize_from_post;
539 }
540 }
541