PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.26
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.26
1.10.27 1.10.26 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 All 171 releases
← All changes | includes/Orders.php +284 -36 1.10.21 → 1.10.26 View file →
@@ -16,12 +16,14 @@
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;
25 +use WCPOS\WooCommercePOS\Payments\Gateway_Contract;
24 26
25 27 /**
26 28 * Orders Class
27 29 * - runs for all life cycles.
@@ -49,8 +51,15 @@
49 51 */
50 52 private static $temp_id_counter = 0;
51 53
52 54 /**
55 + * Armed date_paid suppressions, keyed by order id — see arm_paid_date_suppression().
56 + *
57 + * @var array<int, callable>
58 + */
59 + private $paid_date_suppressions = array();
60 +
61 + /**
53 62 * Constructor.
54 63 */
55 64 public function __construct() {
56 65 $this->register_order_status();
@@ -61,9 +70,13 @@
61 70 add_filter( 'woocommerce_payment_complete_order_status', array( $this, 'payment_complete_order_status' ), 10, 3 );
62 71 add_filter( 'woocommerce_bacs_process_payment_order_status', array( $this, 'offline_process_payment_order_status' ), 10, 2 );
63 72 add_filter( 'woocommerce_cheque_process_payment_order_status', array( $this, 'offline_process_payment_order_status' ), 10, 2 );
64 73 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 );
74 + // PHP_INT_MAX: the redirect is the decision input and gateways rewrite it on
75 + // this same filter — Stripe swaps get_return_url() for a #confirm-pi hash at
76 + // 99999 when the intent needs 3DS. Reading it earlier closes a sale the
77 + // customer is still confirming.
78 + add_filter( 'woocommerce_payment_successful_result', array( $this, 'apply_unpaid_gateway_order_status' ), PHP_INT_MAX, 2 );
66 79 add_filter( 'woocommerce_hidden_order_itemmeta', array( $this, 'hidden_order_itemmeta' ) );
67 80 add_filter( 'woocommerce_order_item_product', array( $this, 'order_item_product' ), 10, 2 );
68 81 add_filter( 'woocommerce_order_get_tax_location', array( $this, 'get_tax_location' ), 10, 2 );
69 82 add_action( 'woocommerce_order_item_after_calculate_taxes', array( $this, 'order_item_after_calculate_taxes' ) );
@@ -177,12 +190,105 @@
177 190 if ( ! woocommerce_pos_is_pos_order( $order ) ) {
178 191 return $status;
179 192 }
180 193
181 - return $this->normalize_status( $this->get_gateway_order_status( $order->get_payment_method() ), $status );
194 + $configured = $this->normalize_status( $this->get_gateway_order_status( $order->get_payment_method() ), $status );
195 +
196 + /*
197 + * The gateway writes this status itself with update_status(), and no money
198 + * has been taken at the till. payment_complete_order_status() reports the
199 + * configured status as this order's paid status, so set_status() would
200 + * stamp date_paid the moment the order lands on it — booking a pending
201 + * "pay by invoice" or on-hold bank-transfer order as revenue. Suppress the
202 + * stamp for that write when the configured status is one WooCommerce
203 + * does not itself call paid; a paid status keeps WooCommerce's own
204 + * behaviour (core stamps date_paid for COD landing on processing).
205 + *
206 + * This filter runs before the gateway's update_status(), so the
207 + * suppression is armed here and released once the order object is saved
208 + * — see release_paid_date_suppression_after_save().
209 + */
210 + if ( $order instanceof WC_Order && ! \in_array( $configured, wc_get_is_paid_statuses(), true ) ) {
211 + $this->arm_paid_date_suppression( $order->get_id() );
212 + }
213 +
214 + return $configured;
182 215 }
183 216
184 217 /**
218 + * Suppress the date_paid stamp for one order until its next save.
219 + *
220 + * Scoped to the order id: a status-transition handler can call
221 + * payment_complete() on a *different* order while this filter is live
222 + * (subscriptions, bundles and gift-card plugins all do), and an
223 + * unconditional '' would reach that order too — set_status() rejects an
224 + * unknown status and falls back to 'pending', leaving an order that was
225 + * just paid sitting unpaid.
226 + *
227 + * Released on `woocommerce_after_order_object_save` for that order, which
228 + * fires whether or not the status actually changed, and on `shutdown` as a
229 + * backstop so a write that never happens cannot leave it armed.
230 + *
231 + * @param int $order_id Order ID.
232 + */
233 + private function arm_paid_date_suppression( int $order_id ): void {
234 + if ( isset( $this->paid_date_suppressions[ $order_id ] ) ) {
235 + return;
236 + }
237 +
238 + $suppress = static function ( $payment_status, $filtered_order_id ) use ( $order_id ) {
239 + return (int) $filtered_order_id === $order_id ? '' : $payment_status;
240 + };
241 +
242 + $this->paid_date_suppressions[ $order_id ] = $suppress;
243 +
244 + add_filter( 'woocommerce_payment_complete_order_status', $suppress, PHP_INT_MAX, 2 );
245 + add_action( 'woocommerce_after_order_object_save', array( $this, 'release_paid_date_suppression_after_save' ), 10, 1 );
246 + add_action( 'shutdown', array( $this, 'release_all_paid_date_suppressions' ) );
247 + }
248 +
249 + /**
250 + * Release the date_paid suppression once the order it was armed for is saved.
251 + *
252 + * @param WC_Abstract_Order $order The saved order.
253 + */
254 + public function release_paid_date_suppression_after_save( $order ): void {
255 + if ( ! $order instanceof WC_Abstract_Order ) {
256 + return;
257 + }
258 +
259 + $this->release_paid_date_suppression( $order->get_id() );
260 + }
261 +
262 + /**
263 + * Release every armed date_paid suppression.
264 + */
265 + public function release_all_paid_date_suppressions(): void {
266 + foreach ( array_keys( $this->paid_date_suppressions ) as $order_id ) {
267 + $this->release_paid_date_suppression( (int) $order_id );
268 + }
269 + }
270 +
271 + /**
272 + * Remove one order's date_paid suppression from the filter.
273 + *
274 + * @param int $order_id Order ID.
275 + */
276 + private function release_paid_date_suppression( int $order_id ): void {
277 + if ( ! isset( $this->paid_date_suppressions[ $order_id ] ) ) {
278 + return;
279 + }
280 +
281 + remove_filter( 'woocommerce_payment_complete_order_status', $this->paid_date_suppressions[ $order_id ], PHP_INT_MAX );
282 + unset( $this->paid_date_suppressions[ $order_id ] );
283 +
284 + if ( empty( $this->paid_date_suppressions ) ) {
285 + remove_action( 'woocommerce_after_order_object_save', array( $this, 'release_paid_date_suppression_after_save' ), 10 );
286 + remove_action( 'shutdown', array( $this, 'release_all_paid_date_suppressions' ) );
287 + }
288 + }
289 +
290 + /**
185 291 * Normalise a configured gateway order status for the WooCommerce status filters.
186 292 *
187 293 * Both `woocommerce_payment_complete_order_status` and the offline gateway
188 294 * `*_process_payment_order_status` filters expect a status *without* the `wc-`
@@ -240,8 +346,17 @@
240 346 * - the gateway must be enabled for POS *and* carry an explicitly stored
241 347 * status. The settings view synthesizes `wc-completed` for every installed
242 348 * gateway it has never seen, so trusting the computed value would mark an
243 349 * unconfigured third-party gateway Completed with no money taken.
350 + * - the gateway's redirect must be the order's own received page. That is
351 + * the gateway saying it is finished; any other target means it is still
352 + * collecting the money — see redirect_targets_order_received().
353 + * - the gateway must not be one that moves money, read from the capability
354 + * WooCommerce has every gateway declare: refunds. A gateway that can give
355 + * money back takes money; if it hands the customer to the received page
356 + * without having taken it, the payment is pending (a bank transfer, an
357 + * async method) and its webhook owns the status. A quote, invoice or
358 + * purchase-order gateway cannot refund, because nothing was ever paid.
244 359 *
245 360 * @param array $result Gateway result, passed through untouched.
246 361 * @param int $order_id Order ID.
247 362 *
@@ -261,9 +376,17 @@
261 376 if ( ! $order->has_status( 'pos-open' ) || $order->get_date_paid( 'edit' ) ) {
262 377 return $result;
263 378 }
264 379
380 + if ( ! \is_array( $result ) || ! $this->redirect_targets_order_received( $result, $order ) ) {
381 + return $result;
382 + }
383 +
265 384 $gateway_id = $order->get_payment_method();
385 +
386 + if ( $this->gateway_moves_money( $gateway_id ) ) {
387 + return $result;
388 + }
266 389 $configured = $this->get_stored_gateway_order_status( $gateway_id );
267 390
268 391 if ( '' === $configured ) {
269 392 return $result;
@@ -322,65 +445,190 @@
322 445 return $result;
323 446 }
324 447
325 448 /**
326 - * Read the explicitly stored per-gateway order status.
449 + * Whether a successful gateway result sends the customer to the order's received page.
327 450 *
328 - * Reads the raw options rather than the settings service, because the service
329 - * rebuilds its view from the installed gateways and synthesizes a default
330 - * status for gateways the merchant has never configured. Only a status the
331 - * merchant actually chose, on a gateway they enabled for POS, counts as intent.
451 + * The redirect is the one thing every gateway declares about what happens
452 + * next, and it separates the two shapes that both "return success and leave
453 + * the order open":
332 454 *
333 - * Two places hold such a choice, matching Payment_Gateways_Section::read():
334 - * the per-gateway entry, and — on sites upgraded from before per-gateway
335 - * statuses — the legacy global `checkout.order_status`, which that section
336 - * still applies in memory to any gateway with no explicit status of its own
337 - * until the merchant next saves.
455 + * - A gateway that is finished sends the customer to the received page,
456 + * `get_return_url( $order )`. Quotes, invoices, purchase orders, BACS,
457 + * cheque and COD all do. No money will ever move through it, so the
458 + * configured POS status is the only thing that closes the sale.
459 + * - A gateway that still has to collect the money sends them somewhere else:
460 + * a hosted checkout off-site (Dintero, Mollie, PayPal, Klarna), or an
461 + * on-site pay or receipt page that posts a form to one. It settles the
462 + * order later, from its callback, through payment_complete(). Acting on
463 + * this shape marked the order Completed while the cashier was still
464 + * looking at the hosted checkout (1.10.20–1.10.22): Dintero's capture
465 + * handler then found no transaction and bounced the order to on-hold, and
466 + * the till — which reads any status outside its open/unpaid set as a
467 + * finished sale — opened the receipt before a payment method was chosen.
338 468 *
339 - * @param string $gateway_id The payment gateway ID.
469 + * Compared without scheme or trailing slash: `get_return_url()` may upgrade to
470 + * https, and a gateway may append its own arguments. Both the order's received
471 + * URL and its `woocommerce_get_return_url`-filtered form are accepted, so a
472 + * plugin that moves the thank-you page still matches — see is_same_page() for
473 + * what "same page" means on plain permalinks, where the page is in the query.
340 474 *
341 - * @return string The stored status (may include the wc- prefix), or '' when absent.
475 + * A redirect carrying a fragment never matches. A fragment on a thank-you URL
476 + * is a gateway's instruction to its own script to do something before the sale
477 + * is done (Stripe's and WooPayments' `#confirm-pi…` 3DS hand-off); a gateway
478 + * that is finished has no reason to add one. Nor does a missing or relative
479 + * redirect match: WooCommerce's pay handler would redirect to it unchanged,
480 + * and nothing here can tell what it is.
481 + *
482 + * @param array $result Gateway result from process_payment().
483 + * @param WC_Order $order The order being paid.
484 + *
485 + * @return bool
342 486 */
343 - private function get_stored_gateway_order_status( string $gateway_id ): string {
344 - if ( '' === $gateway_id ) {
345 - return '';
487 + private function redirect_targets_order_received( array $result, WC_Order $order ): bool {
488 + $redirect = isset( $result['redirect'] ) && \is_string( $result['redirect'] ) ? trim( $result['redirect'] ) : '';
489 +
490 + if ( '' === $redirect || false !== strpos( $redirect, '#' ) ) {
491 + return false;
346 492 }
347 493
348 - $stored = get_option( 'woocommerce_pos_settings_payment_gateways', array() );
494 + $received_url = $order->get_checkout_order_received_url();
349 495
350 - if ( ! \is_array( $stored ) || ! isset( $stored['gateways'][ $gateway_id ] ) || ! \is_array( $stored['gateways'][ $gateway_id ] ) ) {
496 + /** This filter is documented in woocommerce/includes/abstracts/abstract-wc-payment-gateway.php */
497 + $return_url = apply_filters( 'woocommerce_get_return_url', $received_url, $order ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core hook.
498 +
499 + foreach ( array_unique( array( $received_url, $return_url ) ) as $candidate ) {
500 + if ( \is_string( $candidate ) && '' !== $candidate && $this->is_same_page( $redirect, $candidate ) ) {
501 + return true;
502 + }
503 + }
504 +
505 + return false;
506 + }
507 +
508 + /**
509 + * Whether a gateway's redirect lands on the given page.
510 + *
511 + * Host (case-insensitive), port and path (without a trailing slash) must
512 + * match; the scheme is ignored, and a port is compared only when it is not
513 + * the scheme's default, so http and https forms of one origin agree while
514 + * two services on one host do not.
515 + *
516 + * The query is compared one way: every argument the page carries must be on
517 + * the redirect with the same value, except `key`, which a gateway may drop.
518 + * Extra arguments on the redirect are fine (`utm_nooverride`, a gateway's own
519 + * tracking). This is what makes plain permalinks safe, where every
520 + * WooCommerce page shares the path `/` and the page is its query: the
521 + * received page carries `page_id` and the received endpoint (`order-received`,
522 + * or whatever the store renamed it to, read from the URL itself rather than
523 + * assumed), so the checkout page, the pay page and any other page of the same
524 + * site fail to carry one of them.
525 + *
526 + * @param string $redirect The gateway's redirect.
527 + * @param string $page The page it must land on.
528 + *
529 + * @return bool
530 + */
531 + private function is_same_page( string $redirect, string $page ): bool {
532 + $parts_r = wp_parse_url( $redirect );
533 + $parts_p = wp_parse_url( $page );
534 +
535 + if ( ! \is_array( $parts_r ) || ! \is_array( $parts_p ) ) {
536 + return false;
537 + }
538 +
539 + $host_r = strtolower( (string) ( $parts_r['host'] ?? '' ) );
540 + $host_p = strtolower( (string) ( $parts_p['host'] ?? '' ) );
541 +
542 + if ( '' === $host_r || $host_r !== $host_p ) {
543 + return false;
544 + }
545 +
546 + if ( $this->explicit_port( $parts_r ) !== $this->explicit_port( $parts_p ) ) {
547 + return false;
548 + }
549 +
550 + $path_r = untrailingslashit( (string) ( $parts_r['path'] ?? '/' ) );
551 + $path_p = untrailingslashit( (string) ( $parts_p['path'] ?? '/' ) );
552 +
553 + if ( $path_r !== $path_p ) {
554 + return false;
555 + }
556 +
557 + parse_str( (string) ( $parts_r['query'] ?? '' ), $query_r );
558 + parse_str( (string) ( $parts_p['query'] ?? '' ), $query_p );
559 +
560 + foreach ( $query_p as $name => $value ) {
561 + if ( 'key' === $name ) {
562 + continue;
563 + }
564 +
565 + if ( ! isset( $query_r[ $name ] ) || (string) $query_r[ $name ] !== (string) $value ) {
566 + return false;
567 + }
568 + }
569 +
570 + return true;
571 + }
572 +
573 + /**
574 + * The port of a parsed URL, or '' when it is the scheme's default.
575 + *
576 + * @param array $parts Output of wp_parse_url().
577 + *
578 + * @return string
579 + */
580 + private function explicit_port( array $parts ): string {
581 + if ( ! isset( $parts['port'] ) ) {
351 582 return '';
352 583 }
353 584
354 - $gateway = $stored['gateways'][ $gateway_id ];
585 + $port = (int) $parts['port'];
586 + $scheme = strtolower( (string) ( $parts['scheme'] ?? '' ) );
355 587
356 - if ( ! isset( $gateway['enabled'] ) || ! wc_string_to_bool( $gateway['enabled'] ) ) {
588 + if ( ( 'http' === $scheme && 80 === $port ) || ( 'https' === $scheme && 443 === $port ) ) {
357 589 return '';
358 590 }
359 591
360 - if ( isset( $gateway['order_status'] ) && \is_string( $gateway['order_status'] ) && '' !== $gateway['order_status'] ) {
361 - return $gateway['order_status'];
592 + return (string) $port;
593 + }
594 +
595 + /**
596 + * Whether a gateway declares WooCommerce's refund capability.
597 + *
598 + * The declaration is the gateway's own (`$supports`, through
599 + * `WC_Payment_Gateway::supports()` and its filter). A gateway WooCommerce
600 + * does not know is taken as not moving money: the merchant stored a POS status
601 + * for it, and that choice is the only thing left to act on.
602 + *
603 + * @param string $gateway_id The payment gateway ID.
604 + *
605 + * @return bool
606 + */
607 + private function gateway_moves_money( string $gateway_id ): bool {
608 + if ( '' === $gateway_id ) {
609 + return false;
362 610 }
363 611
364 - return $this->get_legacy_checkout_order_status();
612 + $gateways = WC()->payment_gateways()->payment_gateways();
613 + $gateway = $gateways[ $gateway_id ] ?? null;
614 +
615 + return $gateway instanceof WC_Payment_Gateway && $gateway->supports( 'refunds' );
365 616 }
366 617
367 618 /**
368 - * Read the legacy global checkout order status.
619 + * Read the explicitly stored per-gateway order status.
369 620 *
370 - * Pre-dates per-gateway statuses. Payment_Gateways_Section::read() still seeds
371 - * it in memory for gateways with no explicit status, and leaves the key in
372 - * place until the merchant saves, so an upgraded site can have an enabled
373 - * gateway whose only configured status lives here.
621 + * The received page and the POS gateway catalog read the same value, so the
622 + * reader lives on Gateway_Contract — see get_stored_order_status() there for
623 + * why the raw option, and not the settings view, is the source of intent.
374 624 *
375 - * @return string The stored legacy status, or '' when absent.
625 + * @param string $gateway_id The payment gateway ID.
626 + *
627 + * @return string The stored status (may include the wc- prefix), or '' when absent.
376 628 */
377 - private function get_legacy_checkout_order_status(): string {
378 - $checkout = get_option( 'woocommerce_pos_settings_checkout', array() );
379 -
380 - return \is_array( $checkout ) && isset( $checkout['order_status'] ) && \is_string( $checkout['order_status'] )
381 - ? $checkout['order_status']
382 - : '';
629 + private function get_stored_gateway_order_status( string $gateway_id ): string {
630 + return ( new Gateway_Contract() )->get_stored_order_status( $gateway_id );
383 631 }
384 632
385 633 /**
386 634 * Resolve the configured POS order status for a given payment gateway.