PluginProbe
MONEI Payments for WooCommerce / trunk
MONEI Payments for WooCommerce vtrunk
7.3.3 7.3.2 7.3.1 7.3.0 7.2.4 7.2.3 7.2.2 7.2.0 7.2.1 7.1.3 2.1.0 3.0.0 3.1.0 3.1.1 4.0.0 4.1.0 4.1.1 4.2.0 4.2.1 5.0 5.1.0 5.1.1 5.1.2 5.2.2 5.2.3 All 87 releases
monei / src / Services / express / ExpressCheckoutAjaxHandler.php

ExpressCheckoutAjaxHandler.php in MONEI Payments for WooCommerce trunk, at src/Services/express/ExpressCheckoutAjaxHandler.php

1,498 lines 44.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Express checkout AJAX endpoints.
4 *
5 * @package Monei
6 */
7
8 namespace Monei\Services\express;
9
10 use Monei\Core\ContainerProvider;
11 use Monei\Features\Subscriptions\SubscriptionService;
12 use Monei\Gateways\Abstracts\WCMoneiPaymentGateway;
13 use WC_Cart;
14 use WC_Data_Store;
15 use WC_Monei_Logger;
16 use WC_Order;
17 use WC_Product;
18 use WC_Product_Variation;
19 use WC_Session_Handler;
20 use WC_Session;
21 use WC_Validation;
22
23 if ( ! defined( 'ABSPATH' ) ) {
24 exit;
25 }
26
27 /**
28 * Serves the `wc-ajax` endpoints the express checkout wallet buttons call.
29 *
30 * Nonce strategy under page caching: product and cart pages are the pages a store
31 * caches most aggressively, so a nonce printed into that HTML is stale by the time
32 * a shopper uses it and `check_ajax_referer` then fails with no useful signal. The
33 * page therefore prints no authoritative nonce. Express components call
34 * `monei_express_bootstrap` first and use the nonce it returns. `wc-ajax` requests
35 * are never cached — `WC_AJAX::define_ajax()` sets `DONOTCACHEPAGE` and sends
36 * nocache headers — so that response is always fresh. The endpoint issues a nonce
37 * bound to the caller's own session and reads nothing, which is the same shape
38 * WooCommerce core uses for `get_refreshed_fragments`.
39 *
40 * The same call forces the WooCommerce session open, because `sessionId` comes from
41 * `WC()->session->get_customer_id()` and a first-time guest on a product page has no
42 * session cookie yet.
43 */
44 class ExpressCheckoutAjaxHandler {
45
46 /**
47 * Nonce action shared by every express endpoint.
48 */
49 const NONCE_ACTION = 'monei_express_checkout';
50
51 /**
52 * Request field carrying the nonce.
53 */
54 const NONCE_FIELD = 'security';
55
56 /**
57 * Currencies whose minor unit is the currency itself.
58 *
59 * @var string[]
60 */
61 const ZERO_DECIMAL_CURRENCIES = array(
62 'BIF',
63 'CLP',
64 'DJF',
65 'GNF',
66 'JPY',
67 'KMF',
68 'KRW',
69 'MGA',
70 'PYG',
71 'RWF',
72 'UGX',
73 'VND',
74 'VUV',
75 'XAF',
76 'XOF',
77 'XPF',
78 );
79
80 /**
81 * Gateways that expose express checkout.
82 *
83 * @var string[]
84 */
85 const EXPRESS_GATEWAY_CLASSES = array(
86 'Monei\Gateways\PaymentMethods\WCGatewayMoneiAppleGoogle',
87 'Monei\Gateways\PaymentMethods\WCGatewayMoneiPaypal',
88 );
89
90 /**
91 * Resolved express gateways.
92 *
93 * @var WCMoneiPaymentGateway[]|null
94 */
95 private $express_gateways = null;
96
97 /**
98 * Cart snapshot service.
99 *
100 * @var ExpressCartBackup
101 */
102 private $cart_backup;
103
104 /**
105 * @param ExpressCartBackup $cart_backup Cart snapshot service.
106 */
107 public function __construct( ExpressCartBackup $cart_backup ) {
108 $this->cart_backup = $cart_backup;
109 }
110
111 /**
112 * Register the endpoints.
113 *
114 * @return void
115 */
116 public function init() {
117 add_action( 'wc_ajax_monei_express_bootstrap', array( $this, 'ajax_bootstrap' ) );
118 add_action( 'wc_ajax_monei_express_get_cart_details', array( $this, 'ajax_get_cart_details' ) );
119 add_action( 'wc_ajax_monei_express_get_shipping_options', array( $this, 'ajax_get_shipping_options' ) );
120 add_action( 'wc_ajax_monei_express_normalize_address', array( $this, 'ajax_normalize_address' ) );
121 add_action( 'wc_ajax_monei_express_update_shipping_method', array( $this, 'ajax_update_shipping_method' ) );
122 add_action( 'wc_ajax_monei_express_get_selected_product_data', array( $this, 'ajax_get_selected_product_data' ) );
123 add_action( 'wc_ajax_monei_express_add_to_cart', array( $this, 'ajax_add_to_cart' ) );
124 add_action( 'wc_ajax_monei_express_clear_cart', array( $this, 'ajax_clear_cart' ) );
125 add_action( 'wc_ajax_monei_express_create_order', array( $this, 'ajax_create_order' ) );
126 add_action( 'wp', array( $this, 'maybe_start_customer_session' ) );
127 }
128
129 /**
130 * Hands the express components a fresh nonce and the WooCommerce session id.
131 *
132 * @return void
133 */
134 public function ajax_bootstrap() {
135 $this->deny_unless_express_available();
136 $this->start_customer_session();
137
138 wp_send_json(
139 array(
140 'result' => 'success',
141 'nonce' => wp_create_nonce( self::NONCE_ACTION ),
142 'sessionId' => $this->get_session_id(),
143 )
144 );
145 }
146
147 /**
148 * Cart amount, currency and display items in minor units.
149 *
150 * @return void
151 */
152 public function ajax_get_cart_details() {
153 $this->verify_request();
154
155 WC()->cart->calculate_totals();
156
157 wp_send_json( array_merge( array( 'result' => 'success' ), $this->build_cart_payload() ) );
158 }
159
160 /**
161 * Shipping options available for a partial wallet address.
162 *
163 * The wallet sends country, city, state and postcode only — never a street — so
164 * the response must survive an address that cannot be fully validated.
165 *
166 * @return void
167 */
168 public function ajax_get_shipping_options() {
169 $this->verify_request();
170
171 $address = $this->get_posted_address( 'address' );
172
173 if ( ! WC()->cart->needs_shipping() ) {
174 WC()->cart->calculate_totals();
175
176 wp_send_json(
177 array_merge(
178 array(
179 'result' => 'success',
180 'shippingOptions' => array(),
181 ),
182 $this->build_cart_payload()
183 )
184 );
185 }
186
187 $this->apply_shipping_address( $address );
188
189 $options = $this->get_available_shipping_options();
190
191 if ( empty( $options ) ) {
192 wp_send_json(
193 array_merge(
194 array(
195 'result' => 'invalid_shipping_address',
196 'message' => __( 'No shipping method is available for this address.', 'monei' ),
197 'shippingOptions' => array(),
198 ),
199 $this->build_cart_payload()
200 )
201 );
202 }
203
204 // The wallet auto-selects the first option, so the returned amount must be the
205 // total with that option already applied.
206 $this->set_chosen_shipping_method( $options[0]['id'] );
207 WC()->cart->calculate_totals();
208
209 wp_send_json(
210 array_merge(
211 array(
212 'result' => 'success',
213 'shippingOptions' => $options,
214 ),
215 $this->build_cart_payload()
216 )
217 );
218 }
219
220 /**
221 * Wallet address mapped to WooCommerce fields.
222 *
223 * @return void
224 */
225 public function ajax_normalize_address() {
226 $this->verify_request();
227
228 wp_send_json(
229 array(
230 'result' => 'success',
231 'billing' => $this->get_posted_address( 'billing' ),
232 'shipping' => $this->get_posted_address( 'shipping' ),
233 )
234 );
235 }
236
237 /**
238 * Applies the shipping rate the shopper picked in the wallet sheet.
239 *
240 * @return void
241 */
242 public function ajax_update_shipping_method() {
243 $this->verify_request();
244
245 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verify_request() runs check_ajax_referer first.
246 $rate_id = isset( $_POST['shipping_method'] ) ? (string) wc_clean( wp_unslash( $_POST['shipping_method'] ) ) : '';
247
248 WC()->cart->calculate_totals();
249
250 if ( WC()->cart->needs_shipping() ) {
251 // The rate id comes from the client, so it is only honoured when it is one
252 // of the rates this cart and address actually produced.
253 $available = wp_list_pluck( $this->get_available_shipping_options(), 'id' );
254
255 if ( ! in_array( $rate_id, $available, true ) ) {
256 wp_send_json(
257 array_merge(
258 array(
259 'result' => 'invalid_shipping_option',
260 'message' => __( 'That shipping method is not available.', 'monei' ),
261 ),
262 $this->build_cart_payload()
263 )
264 );
265 }
266
267 $this->set_chosen_shipping_method( $rate_id );
268 WC()->cart->calculate_totals();
269 }
270
271 wp_send_json( array_merge( array( 'result' => 'success' ), $this->build_cart_payload() ) );
272 }
273
274 /**
275 * Price and shipping requirement of the product the shopper is looking at.
276 *
277 * The wallet sheet has to open with the right total before the cart has been
278 * touched, so the amount comes from the product rather than from the cart. Once
279 * `add_to_cart` has run, `get_cart_details` is the authority.
280 *
281 * @return void
282 */
283 public function ajax_get_selected_product_data() {
284 $this->verify_request();
285
286 $product = $this->get_posted_product();
287 $quantity = $this->get_posted_quantity();
288 $currency = get_woocommerce_currency();
289
290 wp_send_json(
291 array(
292 'result' => 'success',
293 'productId' => $product->get_id(),
294 'currency' => $currency,
295 'amount' => self::to_minor_units(
296 (float) wc_get_price_to_display( $product, array( 'qty' => $quantity ) ),
297 $currency
298 ),
299 // Mirrors WC_Cart::needs_shipping() rather than asking the product alone:
300 // a store with shipping switched off, or with no method configured
301 // anywhere, would otherwise make the wallet collect a shipping address
302 // that get_cart_details() then says is not needed.
303 'shippingRequired' => wc_shipping_enabled()
304 && wc_get_shipping_method_count( true ) > 0
305 && $product->needs_shipping(),
306 'displayItems' => array(
307 array(
308 'label' => $product->get_name(),
309 'amount' => self::to_minor_units(
310 (float) wc_get_price_to_display( $product, array( 'qty' => $quantity ) ),
311 $currency
312 ),
313 ),
314 ),
315 )
316 );
317 }
318
319 /**
320 * Puts the product being viewed into the cart, on its own.
321 *
322 * ⚠️ The snapshot is taken **first**. Everything after this point can be undone by
323 * `clear_cart`; anything emptied before it cannot.
324 *
325 * @return void
326 */
327 public function ajax_add_to_cart() {
328 $this->verify_request();
329
330 $product = $this->get_posted_product();
331 $quantity = $this->get_posted_quantity();
332
333 $this->cart_backup->save();
334
335 WC()->cart->empty_cart();
336
337 $is_variation = $product instanceof WC_Product_Variation;
338 $variation_id = $is_variation ? $product->get_id() : 0;
339 $parent_id = $is_variation ? $product->get_parent_id() : $product->get_id();
340
341 $key = WC()->cart->add_to_cart(
342 $parent_id,
343 $quantity,
344 $variation_id,
345 $is_variation ? $product->get_variation_attributes() : array()
346 );
347
348 if ( ! $key ) {
349 // The cart is empty at this point, so the shopper's own cart has to go back
350 // before this request returns, whatever the reason for the failure.
351 $this->cart_backup->restore();
352
353 wp_send_json_error(
354 array(
355 'code' => 'add_to_cart_failed',
356 'message' => __( 'This product could not be added to the cart.', 'monei' ),
357 ),
358 400
359 );
360 }
361
362 $this->cart_backup->remember_express_items( array( $key ) );
363
364 WC()->cart->calculate_totals();
365
366 wp_send_json( array_merge( array( 'result' => 'success' ), $this->build_cart_payload() ) );
367 }
368
369 /**
370 * Puts the shopper's own cart back, on every way out of a product page express
371 * flow: cancelled wallet sheet, failed payment, or navigating away.
372 *
373 * @return void
374 */
375 public function ajax_clear_cart() {
376 $this->verify_request();
377
378 wp_send_json(
379 array(
380 'result' => 'success',
381 'restored' => $this->cart_backup->restore(),
382 )
383 );
384 }
385
386 /**
387 * Turns a wallet `SubmitResult` into a paid WooCommerce order.
388 *
389 * This is the only path a product page or a classic cart express payment has: those
390 * surfaces carry no checkout form to submit, unlike the classic checkout page and
391 * the Cart/Checkout blocks, which both go through WooCommerce's own order flow.
392 *
393 * 🚨 `finalAmount` comes from the client. It is never charged, never stored and never
394 * trusted — the total is recomputed here from the cart and the request is refused on
395 * any mismatch. See `amount_matches()`.
396 *
397 * @return void
398 */
399 public function ajax_create_order() {
400 $this->verify_request();
401
402 $location = $this->get_posted_text( 'location' );
403 $gateway = $this->get_gateway_for_payment_method( $this->get_posted_text( 'payment_method' ) );
404
405 if ( ! $gateway instanceof WCMoneiPaymentGateway || ! $gateway->is_express_enabled_at( $location ) ) {
406 $this->fail_order( 'express_disabled', __( 'Express checkout is not available.', 'monei' ) );
407 }
408
409 // The token is read straight out of $_POST by the gateways themselves, under the
410 // same field name the classic checkout form uses, so nothing is passed by hand.
411 if ( '' === $this->get_posted_text( 'monei_payment_request_token' ) ) {
412 $this->fail_order( 'missing_token', __( 'The wallet did not return a payment token.', 'monei' ) );
413 }
414
415 // The token was created against the session the component was initialised with.
416 // A session that rotated in between would be rejected by MONEI with nothing a
417 // shopper could act on, so it is caught here instead.
418 if ( $this->get_posted_text( 'session_id' ) !== $this->get_session_id() ) {
419 $this->fail_order( 'session_mismatch', __( 'Your express checkout session expired. Please reload the page.', 'monei' ) );
420 }
421
422 if ( ! WC()->cart instanceof WC_Cart || WC()->cart->is_empty() ) {
423 $this->fail_order( 'empty_cart', __( 'Your cart is empty.', 'monei' ) );
424 }
425
426 $billing = $this->get_posted_address( 'billing' );
427 $shipping = $this->get_posted_address( 'shipping' );
428
429 if ( '' === $shipping['country'] ) {
430 $shipping = $billing;
431 }
432
433 if ( '' === $billing['country'] ) {
434 $billing = $shipping;
435 }
436
437 // 🚨 The wallet is the only source of an email in express: there is no form for a
438 // guest to type one into, and WooCommerce needs one to create the order. When a
439 // wallet stops returning it the payment fails at the MONEI API instead, as
440 // `Invalid email address at "body.customer.email"`, which reads as a MONEI fault
441 // rather than a missing field — so the contract is checked here, where the
442 // message can name what is actually wrong.
443 if ( ! is_email( $billing['email'] ) ) {
444 $this->fail_order(
445 'missing_billing_email',
446 __( 'The wallet did not return an email address, which is required to place the order.', 'monei' )
447 );
448 }
449
450 // Totals are recomputed exactly the way the shipping callbacks computed the
451 // figure the shopper approved: the customer is pointed at the shipping address
452 // and nothing else. Setting the billing address separately here would move the
453 // total on a store that taxes by billing address, after the wallet had already
454 // shown its own — the order still records the billing address the wallet returned.
455 $this->apply_shipping_address( $shipping );
456 $this->apply_chosen_shipping_option();
457
458 WC()->cart->calculate_totals();
459
460 $currency = get_woocommerce_currency();
461 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verify_request() runs check_ajax_referer first.
462 $final_amount = isset( $_POST['final_amount'] ) ? wc_clean( wp_unslash( $_POST['final_amount'] ) ) : null;
463
464 if ( ! self::amount_matches( WC()->cart->get_total( 'edit' ), $currency, $final_amount ) ) {
465 WC_Monei_Logger::log(
466 sprintf(
467 'Express checkout refused an order: the wallet reported %s and the cart recomputed to %d %s.',
468 is_scalar( $final_amount ) ? (string) $final_amount : 'nothing',
469 self::to_minor_units( WC()->cart->get_total( 'edit' ), $currency ),
470 $currency
471 ),
472 WC_Monei_Logger::LEVEL_ERROR
473 );
474
475 $this->fail_order( 'amount_mismatch', __( 'The payment amount did not match your cart, so nothing was charged. Please try again.', 'monei' ) );
476 }
477
478 $order = $this->create_order( $billing, $shipping, $gateway );
479
480 $this->assert_gateway_can_renew( $order, $gateway );
481
482 $result = $gateway->process_payment( $order->get_id() );
483
484 if ( ! is_array( $result ) || 'success' !== ( isset( $result['result'] ) ? $result['result'] : '' ) || empty( $result['redirect'] ) ) {
485 $order->update_status( 'failed', __( 'Express checkout payment could not be started.', 'monei' ) );
486
487 $this->fail_order( 'payment_failed', $this->take_error_notice() );
488 }
489
490 $order->add_order_note(
491 sprintf(
492 /* translators: %s: express checkout surface, e.g. product page */
493 __( 'Order placed through MONEI express checkout (%s).', 'monei' ),
494 $this->get_location_label( $location )
495 )
496 );
497
498 // The product page flow borrowed the shopper's cart to hold the express item.
499 // The order has taken its copy of it, so the shopper's own cart goes back.
500 if ( $this->cart_backup->has_backup() ) {
501 $this->cart_backup->restore();
502 } else {
503 WC()->cart->empty_cart();
504 }
505
506 wp_send_json(
507 array(
508 'result' => 'success',
509 'orderId' => $order->get_id(),
510 'redirect' => $result['redirect'],
511 )
512 );
513 }
514
515 /**
516 * Builds the WooCommerce order for an express payment.
517 *
518 * `WC_Checkout::create_order()` is the same call the ordinary checkout makes, so
519 * line items, fees, shipping lines, taxes and coupons are all copied from the cart
520 * by WooCommerce itself and the resulting order is indistinguishable from one placed
521 * through the checkout form.
522 *
523 * @param array<string, string> $billing Normalized billing address.
524 * @param array<string, string> $shipping Normalized shipping address.
525 * @param WCMoneiPaymentGateway $gateway Gateway the order is placed with.
526 *
527 * @return WC_Order
528 */
529 private function create_order( array $billing, array $shipping, WCMoneiPaymentGateway $gateway ) {
530 $data = $this->build_order_data( $billing, $shipping, $gateway );
531
532 $order_id = WC()->checkout()->create_order( $data );
533
534 if ( is_wp_error( $order_id ) ) {
535 $this->fail_order( 'order_failed', $order_id->get_error_message() );
536 }
537
538 $order = wc_get_order( $order_id );
539
540 if ( ! $order instanceof WC_Order ) {
541 $this->fail_order( 'order_failed', __( 'Your order could not be created.', 'monei' ) );
542 }
543
544 // Both are what WC_Checkout::process_checkout() does around this point. The
545 // action is what WooCommerce Subscriptions and YITH listen on to build their
546 // subscriptions, so an express order carries them exactly as a normal one does.
547 WC()->session->set( 'order_awaiting_payment', $order->get_id() );
548 $this->persist_session();
549 do_action( 'woocommerce_checkout_order_processed', $order->get_id(), $data, $order );
550
551 return $order;
552 }
553
554 /**
555 * Writes the session out before the gateway is called.
556 *
557 * WooCommerce saves a session on shutdown, which a request hanging inside a
558 * payment gateway never reaches. `order_awaiting_payment` would then be absent
559 * when the shopper tries again, and `WC_Checkout::create_order()` would build a
560 * second order instead of resuming the first. `WC_Checkout::process_order_payment()`
561 * saves at exactly this point for exactly this reason.
562 *
563 * @return void
564 */
565 private function persist_session() {
566 $session = function_exists( 'WC' ) ? WC()->session : null;
567
568 // save_data() lives on the handler, not on the abstract WC_Session a custom
569 // implementation could subclass.
570 if ( $session instanceof WC_Session_Handler ) {
571 $session->save_data();
572 }
573 }
574
575 /**
576 * Refuses an order the chosen gateway would never be able to renew.
577 *
578 * Express checkout supports subscription products, through the same handler the card
579 * gateway uses — but only for a gateway that declares subscription support, because
580 * that declaration is what registers the renewal hook. Taking the first payment for a
581 * subscription that can never be charged again is worse than refusing it.
582 *
583 * @param WC_Order $order Order just created.
584 * @param WCMoneiPaymentGateway $gateway Gateway the order is placed with.
585 *
586 * @return void
587 */
588 private function assert_gateway_can_renew( WC_Order $order, WCMoneiPaymentGateway $gateway ) {
589 $handler = ContainerProvider::getContainer()->get( SubscriptionService::class );
590
591 if ( ! $handler instanceof SubscriptionService ) {
592 return;
593 }
594
595 $handler = $handler->getHandler();
596
597 if ( null === $handler || ! $handler->is_subscription_order( $order->get_id() ) ) {
598 return;
599 }
600
601 if ( $gateway->supports( 'subscriptions' ) ) {
602 return;
603 }
604
605 $order->update_status( 'failed', __( 'Express checkout cannot take subscription payments with this payment method.', 'monei' ) );
606
607 $this->fail_order(
608 'subscription_unsupported',
609 __( 'This product needs the regular checkout. Please continue there.', 'monei' )
610 );
611 }
612
613 /**
614 * Checkout data in the shape `WC_Checkout::create_order()` reads.
615 *
616 * @param array<string, string> $billing Normalized billing address.
617 * @param array<string, string> $shipping Normalized shipping address.
618 * @param WCMoneiPaymentGateway $gateway Gateway the order is placed with.
619 *
620 * @return array<string, string>
621 */
622 private function build_order_data( array $billing, array $shipping, WCMoneiPaymentGateway $gateway ) {
623 $data = array(
624 'payment_method' => $gateway->id,
625 'order_comments' => '',
626 );
627
628 foreach ( array( 'first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country' ) as $field ) {
629 $data[ 'billing_' . $field ] = $billing[ $field ];
630 $data[ 'shipping_' . $field ] = $shipping[ $field ];
631 }
632
633 $data['billing_email'] = $billing['email'];
634 $data['billing_phone'] = $billing['phone'];
635
636 return $data;
637 }
638
639 /**
640 * Applies the shipping method the shopper picked in the wallet sheet.
641 *
642 * The rate id comes from the client, so it is honoured only when it is one the cart
643 * and address actually produced.
644 *
645 * @return void
646 */
647 private function apply_chosen_shipping_option() {
648 if ( ! WC()->cart->needs_shipping() ) {
649 return;
650 }
651
652 $options = $this->get_available_shipping_options();
653
654 if ( empty( $options ) ) {
655 $this->fail_order( 'invalid_shipping_address', __( 'No shipping method is available for this address.', 'monei' ) );
656 }
657
658 $rate_id = $this->get_posted_text( 'shipping_option' );
659
660 if ( ! in_array( $rate_id, wp_list_pluck( $options, 'id' ), true ) ) {
661 // No usable pick means the wallet never offered a choice — a sheet that shows
662 // one option does not always report it — so the option the cart already holds
663 // stands, which is the first of the list the wallet was given.
664 $rate_id = $options[0]['id'];
665 }
666
667 $this->set_chosen_shipping_method( $rate_id );
668 WC()->cart->calculate_totals();
669 }
670
671 /**
672 * Whether a client-supplied amount equals the amount this server computed.
673 *
674 * 🚨 The security boundary of express checkout. The wallet's own figure travels
675 * through the browser and is therefore attacker-controlled; the only figure that may
676 * decide a charge is the one recomputed here from the cart.
677 *
678 * ⚠️ The comparison goes through `to_minor_units()`, never `monei_price_format()`.
679 * The global helper multiplies by 100 for every currency, so on a zero-decimal
680 * currency it would compare 100000 against the wallet's 1000 and refuse every
681 * legitimate JPY payment.
682 *
683 * Only whole minor units are accepted: fractions never come off a wallet, and
684 * rounding one into range is exactly the tampering this guard exists to catch.
685 *
686 * @param float|int|string $expected_amount Server-recomputed total, in major units.
687 * @param string $currency ISO 4217 code.
688 * @param mixed $submitted Raw client value, in minor units.
689 *
690 * @return bool
691 */
692 public static function amount_matches( $expected_amount, $currency, $submitted ) {
693 if ( ! is_scalar( $submitted ) || is_bool( $submitted ) ) {
694 return false;
695 }
696
697 $value = trim( (string) $submitted );
698
699 if ( '' === $value || ! is_numeric( $value ) ) {
700 return false;
701 }
702
703 $minor = (float) $value;
704
705 if ( floor( $minor ) !== $minor ) {
706 return false;
707 }
708
709 return self::to_minor_units( $expected_amount, $currency ) === (int) $minor;
710 }
711
712 /**
713 * The express gateway that issued a wallet token.
714 *
715 * @param string $payment_method `paymentMethod` from the wallet's SubmitResult.
716 *
717 * @return WCMoneiPaymentGateway|null
718 */
719 private function get_gateway_for_payment_method( $payment_method ) {
720 $wanted = 'paypal' === strtolower( $payment_method )
721 ? 'Monei\Gateways\PaymentMethods\WCGatewayMoneiPaypal'
722 : 'Monei\Gateways\PaymentMethods\WCGatewayMoneiAppleGoogle';
723
724 foreach ( $this->get_express_gateways() as $gateway ) {
725 if ( $gateway instanceof $wanted ) {
726 return $gateway;
727 }
728 }
729
730 return null;
731 }
732
733 /**
734 * Restores the borrowed cart, then refuses the request.
735 *
736 * Every way out of order creation goes through here: a shopper whose payment did not
737 * happen must find the cart they had before the wallet opened.
738 *
739 * @param string $code Machine readable reason.
740 * @param string $message Shopper facing message.
741 *
742 * @return void
743 */
744 private function fail_order( $code, $message ) {
745 $this->cart_backup->restore();
746
747 $this->deny( $code, '' !== $message ? $message : __( 'Express checkout could not complete your order.', 'monei' ) );
748 }
749
750 /**
751 * Takes the error WooCommerce queued during payment, so it is reported under the
752 * express button instead of surfacing on whatever page the shopper opens next.
753 *
754 * @return string
755 */
756 private function take_error_notice() {
757 if ( ! function_exists( 'wc_get_notices' ) ) {
758 return '';
759 }
760
761 $notices = wc_get_notices( 'error' );
762
763 wc_clear_notices();
764
765 foreach ( $notices as $notice ) {
766 $message = is_array( $notice ) && isset( $notice['notice'] ) ? $notice['notice'] : $notice;
767
768 if ( is_string( $message ) && '' !== $message ) {
769 return wp_strip_all_tags( $message );
770 }
771 }
772
773 return '';
774 }
775
776 /**
777 * @param string $location Express location key.
778 *
779 * @return string
780 */
781 private function get_location_label( $location ) {
782 $options = WCMoneiPaymentGateway::get_express_location_options();
783
784 return isset( $options[ $location ] ) ? $options[ $location ] : $location;
785 }
786
787 /**
788 * @param string $key POST key.
789 *
790 * @return string
791 */
792 private function get_posted_text( $key ) {
793 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verify_request() runs check_ajax_referer first.
794 if ( ! isset( $_POST[ $key ] ) || ! is_scalar( $_POST[ $key ] ) ) {
795 return '';
796 }
797
798 // phpcs:ignore WordPress.Security.NonceVerification.Missing
799 return (string) wc_clean( wp_unslash( $_POST[ $key ] ) );
800 }
801
802 /**
803 * The product the request refers to, resolved down to a concrete variation.
804 *
805 * Sends an error response and stops rather than returning, because every caller
806 * would otherwise have to repeat the same check.
807 *
808 * @return WC_Product
809 */
810 private function get_posted_product() {
811 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verify_request() runs check_ajax_referer first.
812 $product_id = isset( $_POST['product_id'] ) ? absint( wp_unslash( $_POST['product_id'] ) ) : 0;
813 // phpcs:ignore WordPress.Security.NonceVerification.Missing
814 $variation_id = isset( $_POST['variation_id'] ) ? absint( wp_unslash( $_POST['variation_id'] ) ) : 0;
815
816 $product = wc_get_product( $variation_id > 0 ? $variation_id : $product_id );
817
818 if ( ! $product instanceof WC_Product ) {
819 $this->deny( 'invalid_product', __( 'This product is not available.', 'monei' ) );
820 }
821
822 if ( $product->is_type( 'variable' ) ) {
823 $product = $this->resolve_variation( $product );
824 }
825
826 // Grouped and external products have no price and no single line to buy, and a
827 // wallet sheet cannot ask the questions their pages ask.
828 if ( ! $product->is_purchasable() || ! $product->is_in_stock() ) {
829 $this->deny( 'unsupported_product', __( 'This product cannot be bought with express checkout.', 'monei' ) );
830 }
831
832 return $product;
833 }
834
835 /**
836 * Turns the posted attributes into the one variation they select.
837 *
838 * @param WC_Product $product Variable product.
839 *
840 * @return WC_Product
841 */
842 private function resolve_variation( WC_Product $product ) {
843 $attributes = array();
844
845 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verify_request() runs check_ajax_referer first.
846 if ( isset( $_POST['attributes'] ) && is_array( $_POST['attributes'] ) ) {
847 // phpcs:ignore WordPress.Security.NonceVerification.Missing
848 $attributes = (array) wc_clean( wp_unslash( $_POST['attributes'] ) );
849 }
850
851 $data_store = WC_Data_Store::load( 'product' );
852 // WC_Data_Store proxies to the concrete store through __call, so the method is
853 // invisible to static analysis.
854 /** @phpstan-ignore-next-line */
855 $variation = $data_store->find_matching_product_variation( $product, $attributes );
856 $resolved = $variation ? wc_get_product( $variation ) : null;
857
858 if ( ! $resolved instanceof WC_Product ) {
859 $this->deny( 'variation_not_found', __( 'Please choose product options before paying.', 'monei' ) );
860 }
861
862 return $resolved;
863 }
864
865 /**
866 * @return int
867 */
868 private function get_posted_quantity() {
869 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verify_request() runs check_ajax_referer first.
870 $quantity = isset( $_POST['quantity'] ) ? absint( wp_unslash( $_POST['quantity'] ) ) : 1;
871
872 return max( 1, $quantity );
873 }
874
875 /**
876 * @param string $code Machine readable reason.
877 * @param string $message Shopper facing message.
878 *
879 * @return void
880 */
881 private function deny( $code, $message ) {
882 wp_send_json_error(
883 array(
884 'code' => $code,
885 'message' => $message,
886 ),
887 400
888 );
889 }
890
891 /**
892 * Opens the session on the pages express buttons render on, so the very first
893 * request a guest makes already carries a usable session id.
894 *
895 * @return void
896 */
897 public function maybe_start_customer_session() {
898 if ( is_admin() || wp_doing_ajax() ) {
899 return;
900 }
901
902 $location = $this->get_current_location();
903
904 if ( null === $location || ! $this->is_express_enabled_at( $location ) ) {
905 return;
906 }
907
908 $this->start_customer_session();
909 }
910
911 /**
912 * A positive integer in the smallest unit of the currency, which is what every
913 * MONEI amount on the wire is.
914 *
915 * Matches `monei_price_format()` for every two-decimal currency, so express never
916 * puts a second amount format on the wire. It differs only for the zero-decimal
917 * currencies the global helper multiplies by 100 regardless. Task 22 must verify
918 * the order total with this same method.
919 *
920 * @param float|int|string $amount Amount in major units.
921 * @param string $currency ISO 4217 code.
922 *
923 * @return int
924 */
925 public static function to_minor_units( $amount, $currency = '' ) {
926 $factor = in_array( strtoupper( (string) $currency ), self::ZERO_DECIMAL_CURRENCIES, true ) ? 1 : 100;
927
928 // round() rather than an int cast: (int) ( 10.10 * 100 ) is 1009, because the
929 // product is 1009.9999999999999 in binary floating point.
930 return (int) round( (float) $amount * $factor );
931 }
932
933 /**
934 * Cart amount, currency and display items, all in minor units.
935 *
936 * The display items sum to the amount: subtotal and discount are net of tax, and
937 * tax is a line of its own — the same decomposition WooCommerce totals use.
938 *
939 * @return array<string, mixed>
940 */
941 private function build_cart_payload() {
942 $cart = WC()->cart;
943 $currency = get_woocommerce_currency();
944
945 $items = array(
946 array(
947 'label' => __( 'Subtotal', 'monei' ),
948 'amount' => self::to_minor_units( $cart->get_subtotal(), $currency ),
949 ),
950 );
951
952 $discount = (float) $cart->get_discount_total();
953
954 if ( $discount > 0 ) {
955 $items[] = array(
956 'label' => __( 'Discount', 'monei' ),
957 'amount' => -self::to_minor_units( $discount, $currency ),
958 );
959 }
960
961 foreach ( $cart->get_fees() as $fee ) {
962 $items[] = array(
963 'label' => $fee->name,
964 'amount' => self::to_minor_units( $fee->total, $currency ),
965 );
966 }
967
968 if ( $cart->needs_shipping() ) {
969 $items[] = array(
970 'label' => __( 'Shipping', 'monei' ),
971 'amount' => self::to_minor_units( $cart->get_shipping_total(), $currency ),
972 );
973 }
974
975 $tax = (float) $cart->get_taxes_total();
976
977 if ( wc_tax_enabled() && $tax > 0 ) {
978 $items[] = array(
979 'label' => __( 'Tax', 'monei' ),
980 'amount' => self::to_minor_units( $tax, $currency ),
981 );
982 }
983
984 return array(
985 'currency' => $currency,
986 'amount' => self::to_minor_units( $cart->get_total( false ), $currency ),
987 'shippingRequired' => $cart->needs_shipping(),
988 'displayItems' => $items,
989 );
990 }
991
992 /**
993 * Shipping rates for the current packages, as `{ id, label, amount }`.
994 *
995 * Duplicate rate ids are dropped: a wallet sheet given two options with the same
996 * id never finishes loading.
997 *
998 * ⚠️ KNOWN LIMITATION — carts that WooCommerce splits into more than one shipping
999 * package. A wallet sheet takes exactly one flat list of shipping methods, so it
1000 * cannot express a choice per package. Every package's rates are flattened into
1001 * that one list, each `amount` is the cost of the single package the rate came
1002 * from, and `set_chosen_shipping_method()` writes only `chosen_shipping_methods[0]`
1003 * — so the shopper's pick governs the first package and WooCommerce falls back to
1004 * its own default for the rest.
1005 *
1006 * The charge itself stays correct: the total handed to the wallet is always
1007 * `WC()->cart->get_total()` recomputed after `calculate_totals()`, never a figure
1008 * assembled here. What is wrong is the per-option amount shown in the sheet and
1009 * the shopper's inability to choose for packages after the first.
1010 *
1011 * Not fixed here on purpose. There is no patch — only a redesign of the option
1012 * model that the wallet APIs cannot represent anyway, on a code path with no
1013 * multi-package coverage to redesign it against. Splitting needs several shipping
1014 * zones or per-class packaging, so it is a minority configuration.
1015 *
1016 * @return array<int, array<string, mixed>>
1017 */
1018 private function get_available_shipping_options() {
1019 $currency = get_woocommerce_currency();
1020 $options = array();
1021 $seen = array();
1022
1023 foreach ( WC()->shipping()->get_packages() as $package ) {
1024 if ( empty( $package['rates'] ) ) {
1025 continue;
1026 }
1027
1028 foreach ( $package['rates'] as $rate ) {
1029 if ( in_array( $rate->get_id(), $seen, true ) ) {
1030 continue;
1031 }
1032
1033 $seen[] = $rate->get_id();
1034 $options[] = array(
1035 'id' => $rate->get_id(),
1036 'label' => wp_strip_all_tags( $rate->get_label() ),
1037 'amount' => self::to_minor_units( (float) $rate->get_cost() + (float) $rate->get_shipping_tax(), $currency ),
1038 );
1039 }
1040 }
1041
1042 // Keep the method the shopper already chose first, so the wallet's automatic
1043 // selection of the first option does not silently change it.
1044 $chosen = $this->get_chosen_shipping_method();
1045
1046 if ( '' !== $chosen ) {
1047 usort(
1048 $options,
1049 function ( $a, $b ) use ( $chosen ) {
1050 if ( $a['id'] === $chosen ) {
1051 return -1;
1052 }
1053
1054 if ( $b['id'] === $chosen ) {
1055 return 1;
1056 }
1057
1058 return 0;
1059 }
1060 );
1061 }
1062
1063 return $options;
1064 }
1065
1066 /**
1067 * Points the customer at the wallet-supplied address and recalculates shipping.
1068 *
1069 * @param array<string, string> $address Partial address.
1070 *
1071 * @return void
1072 */
1073 private function apply_shipping_address( array $address ) {
1074 $country = $address['country'];
1075 $state = $address['state'];
1076 $city = $address['city'];
1077 $postcode = $address['postcode'];
1078
1079 WC()->shipping()->reset_shipping();
1080
1081 if ( '' !== $postcode && WC_Validation::is_postcode( $postcode, $country ) ) {
1082 $postcode = wc_format_postcode( $postcode, $country );
1083 }
1084
1085 if ( '' !== $country ) {
1086 WC()->customer->set_location( $country, $state, $postcode, $city );
1087 WC()->customer->set_shipping_location( $country, $state, $postcode, $city );
1088 } else {
1089 WC()->customer->set_billing_address_to_base();
1090 WC()->customer->set_shipping_address_to_base();
1091 }
1092
1093 WC()->customer->set_calculated_shipping( true );
1094 WC()->customer->save();
1095
1096 // calculate_totals() builds the packages through WC_Cart::get_shipping_packages()
1097 // and runs the shipping calculation, so the packages are never hand-rolled here.
1098 WC()->cart->calculate_totals();
1099 }
1100
1101 /**
1102 * @return string
1103 */
1104 private function get_chosen_shipping_method() {
1105 $session = function_exists( 'WC' ) ? WC()->session : null;
1106
1107 if ( ! $session instanceof WC_Session ) {
1108 return '';
1109 }
1110
1111 $chosen = (array) $session->get( 'chosen_shipping_methods', array() );
1112
1113 return isset( $chosen[0] ) ? (string) $chosen[0] : '';
1114 }
1115
1116 /**
1117 * @param string $rate_id Shipping rate id.
1118 *
1119 * @return void
1120 */
1121 private function set_chosen_shipping_method( $rate_id ) {
1122 $session = function_exists( 'WC' ) ? WC()->session : null;
1123
1124 if ( ! $session instanceof WC_Session ) {
1125 return;
1126 }
1127
1128 $chosen = (array) $session->get( 'chosen_shipping_methods', array() );
1129 $chosen[0] = $rate_id;
1130 $session->set( 'chosen_shipping_methods', $chosen );
1131 }
1132
1133 /**
1134 * Reads a posted address object and returns it in WooCommerce format.
1135 *
1136 * @param string $key POST key holding the address object.
1137 *
1138 * @return array<string, string>
1139 */
1140 private function get_posted_address( $key ) {
1141 $raw = array();
1142
1143 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verify_request() runs check_ajax_referer first.
1144 if ( isset( $_POST[ $key ] ) && is_array( $_POST[ $key ] ) ) {
1145 // phpcs:ignore WordPress.Security.NonceVerification.Missing
1146 $raw = (array) wc_clean( wp_unslash( $_POST[ $key ] ) );
1147 }
1148
1149 return $this->normalize_address( $raw );
1150 }
1151
1152 /**
1153 * @param array<string, mixed> $address Wallet address.
1154 *
1155 * @return array<string, string>
1156 */
1157 private function normalize_address( array $address ) {
1158 $normalized = self::map_wallet_address( $address );
1159 $normalized['state'] = self::normalize_state_code(
1160 $normalized['state'],
1161 $this->get_country_states( $normalized['country'] )
1162 );
1163
1164 return $normalized;
1165 }
1166
1167 /**
1168 * @param string $country Two-letter country code.
1169 *
1170 * @return array<string, string>
1171 */
1172 private function get_country_states( $country ) {
1173 if ( '' === $country ) {
1174 return array();
1175 }
1176
1177 $states = WC()->countries->get_states( $country );
1178
1179 return is_array( $states ) ? $states : array();
1180 }
1181
1182 /**
1183 * Maps a wallet address onto WooCommerce address fields.
1184 *
1185 * Wallets disagree on field names — monei.js hands back `{ line1, line2, city,
1186 * state, zip, country }` while Apple Pay, Google Pay and PayPal each use their own
1187 * spelling — and mid-flow the object holds country, city, state and postcode only.
1188 * Every field is therefore optional and every unknown key is ignored.
1189 *
1190 * @param array<string, mixed> $address Wallet address, flat or with a nested `address`.
1191 *
1192 * @return array<string, string>
1193 */
1194 public static function map_wallet_address( array $address ) {
1195 $nested = isset( $address['address'] ) && is_array( $address['address'] ) ? $address['address'] : array();
1196 $fields = array_merge( array_filter( $address, 'is_scalar' ), array_filter( $nested, 'is_scalar' ) );
1197
1198 $mapped = array(
1199 'first_name' => self::first_value( $fields, array( 'first_name', 'firstName', 'givenName', 'given_name' ) ),
1200 'last_name' => self::first_value( $fields, array( 'last_name', 'lastName', 'surname', 'familyName', 'family_name' ) ),
1201 'company' => self::first_value( $fields, array( 'company', 'organization' ) ),
1202 'address_1' => self::first_value( $fields, array( 'address_1', 'line1', 'addressLine1', 'address_line_1' ) ),
1203 'address_2' => self::first_value( $fields, array( 'address_2', 'line2', 'addressLine2', 'address_line_2' ) ),
1204 'city' => self::first_value( $fields, array( 'city', 'locality', 'admin_area_2' ) ),
1205 'state' => self::first_value( $fields, array( 'state', 'region', 'province', 'administrativeArea', 'admin_area_1' ) ),
1206 'postcode' => self::first_value( $fields, array( 'postcode', 'zip', 'postalCode', 'postal_code' ) ),
1207 'country' => strtoupper( self::first_value( $fields, array( 'country', 'countryCode', 'country_code' ) ) ),
1208 'email' => self::first_value( $fields, array( 'email', 'emailAddress', 'email_address' ) ),
1209 'phone' => self::first_value( $fields, array( 'phone', 'phoneNumber', 'phone_number', 'telephone' ) ),
1210 );
1211
1212 if ( '' === $mapped['first_name'] && '' === $mapped['last_name'] ) {
1213 $name = self::first_value( $fields, array( 'name', 'fullName', 'full_name', 'recipient' ) );
1214
1215 if ( '' !== $name ) {
1216 $parts = preg_split( '/\s+/', $name );
1217 $parts = is_array( $parts ) ? $parts : array( $name );
1218
1219 $mapped['last_name'] = count( $parts ) > 1 ? (string) array_pop( $parts ) : '';
1220 $mapped['first_name'] = implode( ' ', $parts );
1221 }
1222 }
1223
1224 return $mapped;
1225 }
1226
1227 /**
1228 * Resolves a state to the code WooCommerce stores.
1229 *
1230 * This is where express checkout usually breaks: wallets send a display name
1231 * ("Madrid", "California"), sometimes decorated ("Co. Clare") and sometimes without
1232 * accents, while WooCommerce shipping zones and address validation match on the
1233 * code. Countries WooCommerce has no state list for keep the value untouched.
1234 *
1235 * @param string $state Value from the wallet.
1236 * @param array<string, string> $wc_states WooCommerce states for the country, code => name.
1237 *
1238 * @return string
1239 */
1240 public static function normalize_state_code( $state, array $wc_states ) {
1241 $state = trim( (string) $state );
1242
1243 if ( '' === $state || empty( $wc_states ) ) {
1244 return $state;
1245 }
1246
1247 if ( isset( $wc_states[ $state ] ) ) {
1248 return $state;
1249 }
1250
1251 foreach ( array_keys( $wc_states ) as $code ) {
1252 if ( 0 === strcasecmp( (string) $code, $state ) ) {
1253 return (string) $code;
1254 }
1255 }
1256
1257 $needle = self::fold( $state );
1258
1259 // Exact name match runs as its own pass: a containment pass alone would resolve
1260 // "West Virginia" to Virginia, whichever key WooCommerce happens to list first.
1261 foreach ( $wc_states as $code => $name ) {
1262 if ( self::fold( $name ) === $needle ) {
1263 return (string) $code;
1264 }
1265 }
1266
1267 foreach ( $wc_states as $code => $name ) {
1268 $folded = self::fold( $name );
1269
1270 if ( '' !== $folded && false !== strpos( $needle, $folded ) ) {
1271 return (string) $code;
1272 }
1273 }
1274
1275 return $state;
1276 }
1277
1278 /**
1279 * Lowercases, strips accents and drops punctuation, so "Málaga", "Malaga" and
1280 * "MALAGA." all compare equal.
1281 *
1282 * @param string $value Value to fold.
1283 *
1284 * @return string
1285 */
1286 private static function fold( $value ) {
1287 $value = mb_strtolower( (string) $value, 'UTF-8' );
1288
1289 $value = strtr(
1290 $value,
1291 array(
1292 'á' => 'a',
1293 'à' => 'a',
1294 'â' => 'a',
1295 'ä' => 'a',
1296 'ã' => 'a',
1297 'å' => 'a',
1298 'é' => 'e',
1299 'è' => 'e',
1300 'ê' => 'e',
1301 'ë' => 'e',
1302 'í' => 'i',
1303 'ì' => 'i',
1304 'î' => 'i',
1305 'ï' => 'i',
1306 'ó' => 'o',
1307 'ò' => 'o',
1308 'ô' => 'o',
1309 'ö' => 'o',
1310 'õ' => 'o',
1311 'ú' => 'u',
1312 'ù' => 'u',
1313 'û' => 'u',
1314 'ü' => 'u',
1315 'ñ' => 'n',
1316 'ç' => 'c',
1317 )
1318 );
1319
1320 $value = (string) preg_replace( '/[^a-z0-9]+/', ' ', $value );
1321
1322 return trim( $value );
1323 }
1324
1325 /**
1326 * First non-empty scalar among the given keys.
1327 *
1328 * @param array<string, mixed> $source Source data.
1329 * @param string[] $keys Keys to try, in order.
1330 *
1331 * @return string
1332 */
1333 private static function first_value( array $source, array $keys ) {
1334 foreach ( $keys as $key ) {
1335 if ( ! isset( $source[ $key ] ) || ! is_scalar( $source[ $key ] ) ) {
1336 continue;
1337 }
1338
1339 $value = trim( (string) $source[ $key ] );
1340
1341 if ( '' !== $value ) {
1342 return $value;
1343 }
1344 }
1345
1346 return '';
1347 }
1348
1349 /**
1350 * Rejects the request unless express checkout is on and the nonce is valid.
1351 *
1352 * @return void
1353 */
1354 private function verify_request() {
1355 $this->deny_unless_express_available();
1356
1357 if ( ! check_ajax_referer( self::NONCE_ACTION, self::NONCE_FIELD, false ) ) {
1358 wp_send_json_error(
1359 array(
1360 'code' => 'invalid_nonce',
1361 'message' => __( 'Your express checkout session expired. Please reload the page.', 'monei' ),
1362 ),
1363 403
1364 );
1365 }
1366
1367 $this->start_customer_session();
1368
1369 if ( ! defined( 'WOOCOMMERCE_CART' ) ) {
1370 define( 'WOOCOMMERCE_CART', true );
1371 }
1372 }
1373
1374 /**
1375 * @return void
1376 */
1377 private function deny_unless_express_available() {
1378 if ( $this->is_express_available() ) {
1379 return;
1380 }
1381
1382 wp_send_json_error(
1383 array(
1384 'code' => 'express_disabled',
1385 'message' => __( 'Express checkout is not available.', 'monei' ),
1386 ),
1387 403
1388 );
1389 }
1390
1391 /**
1392 * True when any express gateway is enabled at any location.
1393 *
1394 * @return bool
1395 */
1396 private function is_express_available() {
1397 foreach ( array_keys( WCMoneiPaymentGateway::get_express_location_options() ) as $location ) {
1398 if ( $this->is_express_enabled_at( (string) $location ) ) {
1399 return true;
1400 }
1401 }
1402
1403 return false;
1404 }
1405
1406 /**
1407 * True when any express gateway is enabled at the given location.
1408 *
1409 * @param string $location One of the keys of get_express_location_options().
1410 *
1411 * @return bool
1412 */
1413 private function is_express_enabled_at( $location ) {
1414 foreach ( $this->get_express_gateways() as $gateway ) {
1415 if ( $gateway->is_express_enabled_at( $location ) ) {
1416 return true;
1417 }
1418 }
1419
1420 return false;
1421 }
1422
1423 /**
1424 * Gateways are resolved on demand rather than injected: the container builds them
1425 * eagerly on construction, and this service is instantiated during `init`, before
1426 * WooCommerce asks for its payment gateways.
1427 *
1428 * @return WCMoneiPaymentGateway[]
1429 */
1430 private function get_express_gateways() {
1431 if ( null !== $this->express_gateways ) {
1432 return $this->express_gateways;
1433 }
1434
1435 $container = ContainerProvider::getContainer();
1436 $this->express_gateways = array();
1437
1438 foreach ( self::EXPRESS_GATEWAY_CLASSES as $class_name ) {
1439 $gateway = $container->get( $class_name );
1440
1441 if ( $gateway instanceof WCMoneiPaymentGateway ) {
1442 $this->express_gateways[] = $gateway;
1443 }
1444 }
1445
1446 return $this->express_gateways;
1447 }
1448
1449 /**
1450 * @return string|null
1451 */
1452 private function get_current_location() {
1453 if ( is_product() ) {
1454 return 'product';
1455 }
1456
1457 if ( is_cart() ) {
1458 return 'cart';
1459 }
1460
1461 if ( is_checkout() ) {
1462 return 'checkout';
1463 }
1464
1465 return null;
1466 }
1467
1468 /**
1469 * @return void
1470 */
1471 private function start_customer_session() {
1472 $session = function_exists( 'WC' ) ? WC()->session : null;
1473
1474 // has_session()/set_customer_session_cookie() live on the handler, not on the
1475 // abstract WC_Session a custom implementation could subclass.
1476 if ( ! $session instanceof WC_Session_Handler ) {
1477 return;
1478 }
1479
1480 if ( ! $session->has_session() ) {
1481 $session->set_customer_session_cookie( true );
1482 }
1483 }
1484
1485 /**
1486 * @return string
1487 */
1488 private function get_session_id() {
1489 $session = function_exists( 'WC' ) ? WC()->session : null;
1490
1491 if ( ! $session instanceof WC_Session ) {
1492 return '';
1493 }
1494
1495 return (string) $session->get_customer_id();
1496 }
1497 }
1498