PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.24
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.24
1.10.25 1.10.24 1.10.23 1.10.22 1.10.21 1.10.20 1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 All 169 releases
← All changes | includes/Orders.php +193 -1 1.10.22 → 1.10.24 View file →
@@ -16,8 +16,9 @@
16 16 use WC_Order;
17 17 use WC_Order_Item;
18 18 use WC_Order_Item_Product;
19 19 use WC_Order_Item_Shipping;
20 +use WC_Payment_Gateway;
20 21 use WC_Discounts;
21 22 use WC_Product;
22 23 use WC_Product_Simple;
23 24 use WC_Tax;
@@ -61,9 +62,13 @@
61 62 add_filter( 'woocommerce_payment_complete_order_status', array( $this, 'payment_complete_order_status' ), 10, 3 );
62 63 add_filter( 'woocommerce_bacs_process_payment_order_status', array( $this, 'offline_process_payment_order_status' ), 10, 2 );
63 64 add_filter( 'woocommerce_cheque_process_payment_order_status', array( $this, 'offline_process_payment_order_status' ), 10, 2 );
64 65 add_filter( 'woocommerce_cod_process_payment_order_status', array( $this, 'offline_process_payment_order_status' ), 10, 2 );
65 - add_filter( 'woocommerce_payment_successful_result', array( $this, 'apply_unpaid_gateway_order_status' ), 10, 2 );
66 + // PHP_INT_MAX: the redirect is the decision input and gateways rewrite it on
67 + // this same filter — Stripe swaps get_return_url() for a #confirm-pi hash at
68 + // 99999 when the intent needs 3DS. Reading it earlier closes a sale the
69 + // customer is still confirming.
70 + add_filter( 'woocommerce_payment_successful_result', array( $this, 'apply_unpaid_gateway_order_status' ), PHP_INT_MAX, 2 );
66 71 add_filter( 'woocommerce_hidden_order_itemmeta', array( $this, 'hidden_order_itemmeta' ) );
67 72 add_filter( 'woocommerce_order_item_product', array( $this, 'order_item_product' ), 10, 2 );
68 73 add_filter( 'woocommerce_order_get_tax_location', array( $this, 'get_tax_location' ), 10, 2 );
69 74 add_action( 'woocommerce_order_item_after_calculate_taxes', array( $this, 'order_item_after_calculate_taxes' ) );
@@ -240,8 +245,17 @@
240 245 * - the gateway must be enabled for POS *and* carry an explicitly stored
241 246 * status. The settings view synthesizes `wc-completed` for every installed
242 247 * gateway it has never seen, so trusting the computed value would mark an
243 248 * unconfigured third-party gateway Completed with no money taken.
249 + * - the gateway's redirect must be the order's own received page. That is
250 + * the gateway saying it is finished; any other target means it is still
251 + * collecting the money — see redirect_targets_order_received().
252 + * - the gateway must not be one that moves money, read from the capability
253 + * WooCommerce has every gateway declare: refunds. A gateway that can give
254 + * money back takes money; if it hands the customer to the received page
255 + * without having taken it, the payment is pending (a bank transfer, an
256 + * async method) and its webhook owns the status. A quote, invoice or
257 + * purchase-order gateway cannot refund, because nothing was ever paid.
244 258 *
245 259 * @param array $result Gateway result, passed through untouched.
246 260 * @param int $order_id Order ID.
247 261 *
@@ -261,9 +275,17 @@
261 275 if ( ! $order->has_status( 'pos-open' ) || $order->get_date_paid( 'edit' ) ) {
262 276 return $result;
263 277 }
264 278
279 + if ( ! \is_array( $result ) || ! $this->redirect_targets_order_received( $result, $order ) ) {
280 + return $result;
281 + }
282 +
265 283 $gateway_id = $order->get_payment_method();
284 +
285 + if ( $this->gateway_moves_money( $gateway_id ) ) {
286 + return $result;
287 + }
266 288 $configured = $this->get_stored_gateway_order_status( $gateway_id );
267 289
268 290 if ( '' === $configured ) {
269 291 return $result;
@@ -319,8 +341,178 @@
319 341 remove_filter( 'woocommerce_payment_complete_order_status', $suppress_paid_date, PHP_INT_MAX );
320 342 }
321 343
322 344 return $result;
345 + }
346 +
347 + /**
348 + * Whether a successful gateway result sends the customer to the order's received page.
349 + *
350 + * The redirect is the one thing every gateway declares about what happens
351 + * next, and it separates the two shapes that both "return success and leave
352 + * the order open":
353 + *
354 + * - A gateway that is finished sends the customer to the received page,
355 + * `get_return_url( $order )`. Quotes, invoices, purchase orders, BACS,
356 + * cheque and COD all do. No money will ever move through it, so the
357 + * configured POS status is the only thing that closes the sale.
358 + * - A gateway that still has to collect the money sends them somewhere else:
359 + * a hosted checkout off-site (Dintero, Mollie, PayPal, Klarna), or an
360 + * on-site pay or receipt page that posts a form to one. It settles the
361 + * order later, from its callback, through payment_complete(). Acting on
362 + * this shape marked the order Completed while the cashier was still
363 + * looking at the hosted checkout (1.10.20–1.10.22): Dintero's capture
364 + * handler then found no transaction and bounced the order to on-hold, and
365 + * the till — which reads any status outside its open/unpaid set as a
366 + * finished sale — opened the receipt before a payment method was chosen.
367 + *
368 + * Compared without scheme or trailing slash: `get_return_url()` may upgrade to
369 + * https, and a gateway may append its own arguments. Both the order's received
370 + * URL and its `woocommerce_get_return_url`-filtered form are accepted, so a
371 + * plugin that moves the thank-you page still matches — see is_same_page() for
372 + * what "same page" means on plain permalinks, where the page is in the query.
373 + *
374 + * A redirect carrying a fragment never matches. A fragment on a thank-you URL
375 + * is a gateway's instruction to its own script to do something before the sale
376 + * is done (Stripe's and WooPayments' `#confirm-pi…` 3DS hand-off); a gateway
377 + * that is finished has no reason to add one. Nor does a missing or relative
378 + * redirect match: WooCommerce's pay handler would redirect to it unchanged,
379 + * and nothing here can tell what it is.
380 + *
381 + * @param array $result Gateway result from process_payment().
382 + * @param WC_Order $order The order being paid.
383 + *
384 + * @return bool
385 + */
386 + private function redirect_targets_order_received( array $result, WC_Order $order ): bool {
387 + $redirect = isset( $result['redirect'] ) && \is_string( $result['redirect'] ) ? trim( $result['redirect'] ) : '';
388 +
389 + if ( '' === $redirect || false !== strpos( $redirect, '#' ) ) {
390 + return false;
391 + }
392 +
393 + $received_url = $order->get_checkout_order_received_url();
394 +
395 + /** This filter is documented in woocommerce/includes/abstracts/abstract-wc-payment-gateway.php */
396 + $return_url = apply_filters( 'woocommerce_get_return_url', $received_url, $order ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core hook.
397 +
398 + foreach ( array_unique( array( $received_url, $return_url ) ) as $candidate ) {
399 + if ( \is_string( $candidate ) && '' !== $candidate && $this->is_same_page( $redirect, $candidate ) ) {
400 + return true;
401 + }
402 + }
403 +
404 + return false;
405 + }
406 +
407 + /**
408 + * Whether a gateway's redirect lands on the given page.
409 + *
410 + * Host (case-insensitive), port and path (without a trailing slash) must
411 + * match; the scheme is ignored, and a port is compared only when it is not
412 + * the scheme's default, so http and https forms of one origin agree while
413 + * two services on one host do not.
414 + *
415 + * The query is compared one way: every argument the page carries must be on
416 + * the redirect with the same value, except `key`, which a gateway may drop.
417 + * Extra arguments on the redirect are fine (`utm_nooverride`, a gateway's own
418 + * tracking). This is what makes plain permalinks safe, where every
419 + * WooCommerce page shares the path `/` and the page is its query: the
420 + * received page carries `page_id` and the received endpoint (`order-received`,
421 + * or whatever the store renamed it to, read from the URL itself rather than
422 + * assumed), so the checkout page, the pay page and any other page of the same
423 + * site fail to carry one of them.
424 + *
425 + * @param string $redirect The gateway's redirect.
426 + * @param string $page The page it must land on.
427 + *
428 + * @return bool
429 + */
430 + private function is_same_page( string $redirect, string $page ): bool {
431 + $parts_r = wp_parse_url( $redirect );
432 + $parts_p = wp_parse_url( $page );
433 +
434 + if ( ! \is_array( $parts_r ) || ! \is_array( $parts_p ) ) {
435 + return false;
436 + }
437 +
438 + $host_r = strtolower( (string) ( $parts_r['host'] ?? '' ) );
439 + $host_p = strtolower( (string) ( $parts_p['host'] ?? '' ) );
440 +
441 + if ( '' === $host_r || $host_r !== $host_p ) {
442 + return false;
443 + }
444 +
445 + if ( $this->explicit_port( $parts_r ) !== $this->explicit_port( $parts_p ) ) {
446 + return false;
447 + }
448 +
449 + $path_r = untrailingslashit( (string) ( $parts_r['path'] ?? '/' ) );
450 + $path_p = untrailingslashit( (string) ( $parts_p['path'] ?? '/' ) );
451 +
452 + if ( $path_r !== $path_p ) {
453 + return false;
454 + }
455 +
456 + parse_str( (string) ( $parts_r['query'] ?? '' ), $query_r );
457 + parse_str( (string) ( $parts_p['query'] ?? '' ), $query_p );
458 +
459 + foreach ( $query_p as $name => $value ) {
460 + if ( 'key' === $name ) {
461 + continue;
462 + }
463 +
464 + if ( ! isset( $query_r[ $name ] ) || (string) $query_r[ $name ] !== (string) $value ) {
465 + return false;
466 + }
467 + }
468 +
469 + return true;
470 + }
471 +
472 + /**
473 + * The port of a parsed URL, or '' when it is the scheme's default.
474 + *
475 + * @param array $parts Output of wp_parse_url().
476 + *
477 + * @return string
478 + */
479 + private function explicit_port( array $parts ): string {
480 + if ( ! isset( $parts['port'] ) ) {
481 + return '';
482 + }
483 +
484 + $port = (int) $parts['port'];
485 + $scheme = strtolower( (string) ( $parts['scheme'] ?? '' ) );
486 +
487 + if ( ( 'http' === $scheme && 80 === $port ) || ( 'https' === $scheme && 443 === $port ) ) {
488 + return '';
489 + }
490 +
491 + return (string) $port;
492 + }
493 +
494 + /**
495 + * Whether a gateway declares WooCommerce's refund capability.
496 + *
497 + * The declaration is the gateway's own (`$supports`, through
498 + * `WC_Payment_Gateway::supports()` and its filter). A gateway WooCommerce
499 + * does not know is taken as not moving money: the merchant stored a POS status
500 + * for it, and that choice is the only thing left to act on.
501 + *
502 + * @param string $gateway_id The payment gateway ID.
503 + *
504 + * @return bool
505 + */
506 + private function gateway_moves_money( string $gateway_id ): bool {
507 + if ( '' === $gateway_id ) {
508 + return false;
509 + }
510 +
511 + $gateways = WC()->payment_gateways()->payment_gateways();
512 + $gateway = $gateways[ $gateway_id ] ?? null;
513 +
514 + return $gateway instanceof WC_Payment_Gateway && $gateway->supports( 'refunds' );
323 515 }
324 516
325 517 /**
326 518 * Read the explicitly stored per-gateway order status.