PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.23
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.23
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 1.10.1 All 168 releases
← All changes | includes/Orders.php +358 -0 1.10.10 → 1.10.23 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,8 +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 );
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 );
65 71 add_filter( 'woocommerce_hidden_order_itemmeta', array( $this, 'hidden_order_itemmeta' ) );
66 72 add_filter( 'woocommerce_order_item_product', array( $this, 'order_item_product' ), 10, 2 );
67 73 add_filter( 'woocommerce_order_get_tax_location', array( $this, 'get_tax_location' ), 10, 2 );
68 74 add_action( 'woocommerce_order_item_after_calculate_taxes', array( $this, 'order_item_after_calculate_taxes' ) );
@@ -213,8 +219,360 @@
213 219
214 220 return \in_array( $normalized_status, $valid_statuses, true )
215 221 ? $normalized_status
216 222 : $fallback;
223 + }
224 +
225 + /**
226 + * Apply the configured POS order status when a gateway settles without payment.
227 + *
228 + * The generic form of offline_process_payment_order_status(). Those three
229 + * gateways are hooked by name only because BACS, cheque and COD each expose a
230 + * `woocommerce_{id}_process_payment_order_status` filter. A gateway that takes
231 + * no money at the till and exposes no such filter — a quote, invoice or
232 + * purchase-order gateway — returned success while leaving the order at
233 + * pos-open, so the configured status was never applied and the till never
234 + * finished the sale. `woocommerce_payment_successful_result` is the seam every
235 + * gateway passes through: WooCommerce applies it after any successful
236 + * process_payment(), including on the POS pay page.
237 + *
238 + * Deliberately narrow, because "returned success but left the order open" is
239 + * also what a gateway awaiting an async confirmation looks like:
240 + *
241 + * - `pos-open` only, never `pos-partial` — a partial tender is still owed
242 + * money, and closing it would lose that.
243 + * - no `date_paid` — money moving means the payment_complete path owns the
244 + * status.
245 + * - the gateway must be enabled for POS *and* carry an explicitly stored
246 + * status. The settings view synthesizes `wc-completed` for every installed
247 + * gateway it has never seen, so trusting the computed value would mark an
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.
258 + *
259 + * @param array $result Gateway result, passed through untouched.
260 + * @param int $order_id Order ID.
261 + *
262 + * @return array
263 + */
264 + public function apply_unpaid_gateway_order_status( $result, $order_id ) {
265 + if ( ! woocommerce_pos_request() ) {
266 + return $result;
267 + }
268 +
269 + $order = wc_get_order( $order_id );
270 +
271 + if ( ! $order instanceof WC_Order || ! woocommerce_pos_is_pos_order( $order ) ) {
272 + return $result;
273 + }
274 +
275 + if ( ! $order->has_status( 'pos-open' ) || $order->get_date_paid( 'edit' ) ) {
276 + return $result;
277 + }
278 +
279 + if ( ! \is_array( $result ) || ! $this->redirect_targets_order_received( $result, $order ) ) {
280 + return $result;
281 + }
282 +
283 + $gateway_id = $order->get_payment_method();
284 +
285 + if ( $this->gateway_moves_money( $gateway_id ) ) {
286 + return $result;
287 + }
288 + $configured = $this->get_stored_gateway_order_status( $gateway_id );
289 +
290 + if ( '' === $configured ) {
291 + return $result;
292 + }
293 +
294 + $status = $this->normalize_status( $configured, '' );
295 +
296 + if ( '' === $status || $order->has_status( $status ) ) {
297 + return $result;
298 + }
299 +
300 + /*
301 + * payment_complete_order_status() reports the configured status as this
302 + * order's paid status, which makes WC_Order::set_status() stamp date_paid
303 + * the moment the status changes — booking an unpaid order as revenue. No
304 + * payment was taken here, so suppress it for this transition only.
305 + *
306 + * Scoped to this order id: a status-transition handler can call
307 + * payment_complete() on a *different* order while this filter is live
308 + * (subscriptions, bundles and gift-card plugins all do), and an
309 + * unconditional '' would reach that order too — set_status() rejects an
310 + * unknown status and falls back to 'pending', leaving an order that was
311 + * just paid sitting unpaid.
312 + */
313 + $target_id = $order->get_id();
314 + $suppress_paid_date = static function ( $payment_status, $filtered_order_id ) use ( $target_id ) {
315 + return (int) $filtered_order_id === $target_id ? '' : $payment_status;
316 + };
317 +
318 + add_filter( 'woocommerce_payment_complete_order_status', $suppress_paid_date, PHP_INT_MAX, 2 );
319 +
320 + try {
321 + /*
322 + * update_status()'s return value is deliberately not checked. It reports
323 + * false only when the order has no id — impossible here — because
324 + * WC_Abstract_Order::save() and WC_Order::status_transition() each catch
325 + * Exception themselves and handle_exception() does not rethrow, so a
326 + * throwing hook never reaches update_status()'s own catch and it still
327 + * returns true. Nor could the checkout be aborted from here:
328 + * WC_Form_Handler::pay_action() applies this filter inside its
329 + * `'success' === $result['result']` branch and redirects unconditionally
330 + * on the next line.
331 + */
332 + $order->update_status(
333 + $status,
334 + /* translators: %s: payment gateway title. */
335 + sprintf( __( 'Order status set by %s; no payment was taken at the till.', 'woocommerce-pos' ), $order->get_payment_method_title() )
336 + );
337 + } finally {
338 + // Must come off even if a status-change handler throws: left in place it
339 + // would suppress the configured status, and date_paid, for every later
340 + // payment in this request.
341 + remove_filter( 'woocommerce_payment_complete_order_status', $suppress_paid_date, PHP_INT_MAX );
342 + }
343 +
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' );
515 + }
516 +
517 + /**
518 + * Read the explicitly stored per-gateway order status.
519 + *
520 + * Reads the raw options rather than the settings service, because the service
521 + * rebuilds its view from the installed gateways and synthesizes a default
522 + * status for gateways the merchant has never configured. Only a status the
523 + * merchant actually chose, on a gateway they enabled for POS, counts as intent.
524 + *
525 + * Two places hold such a choice, matching Payment_Gateways_Section::read():
526 + * the per-gateway entry, and — on sites upgraded from before per-gateway
527 + * statuses — the legacy global `checkout.order_status`, which that section
528 + * still applies in memory to any gateway with no explicit status of its own
529 + * until the merchant next saves.
530 + *
531 + * @param string $gateway_id The payment gateway ID.
532 + *
533 + * @return string The stored status (may include the wc- prefix), or '' when absent.
534 + */
535 + private function get_stored_gateway_order_status( string $gateway_id ): string {
536 + if ( '' === $gateway_id ) {
537 + return '';
538 + }
539 +
540 + $stored = get_option( 'woocommerce_pos_settings_payment_gateways', array() );
541 +
542 + if ( ! \is_array( $stored ) || ! isset( $stored['gateways'][ $gateway_id ] ) || ! \is_array( $stored['gateways'][ $gateway_id ] ) ) {
543 + return '';
544 + }
545 +
546 + $gateway = $stored['gateways'][ $gateway_id ];
547 +
548 + if ( ! isset( $gateway['enabled'] ) || ! wc_string_to_bool( $gateway['enabled'] ) ) {
549 + return '';
550 + }
551 +
552 + if ( isset( $gateway['order_status'] ) && \is_string( $gateway['order_status'] ) && '' !== $gateway['order_status'] ) {
553 + return $gateway['order_status'];
554 + }
555 +
556 + return $this->get_legacy_checkout_order_status();
557 + }
558 +
559 + /**
560 + * Read the legacy global checkout order status.
561 + *
562 + * Pre-dates per-gateway statuses. Payment_Gateways_Section::read() still seeds
563 + * it in memory for gateways with no explicit status, and leaves the key in
564 + * place until the merchant saves, so an upgraded site can have an enabled
565 + * gateway whose only configured status lives here.
566 + *
567 + * @return string The stored legacy status, or '' when absent.
568 + */
569 + private function get_legacy_checkout_order_status(): string {
570 + $checkout = get_option( 'woocommerce_pos_settings_checkout', array() );
571 +
572 + return \is_array( $checkout ) && isset( $checkout['order_status'] ) && \is_string( $checkout['order_status'] )
573 + ? $checkout['order_status']
574 + : '';
217 575 }
218 576
219 577 /**
220 578 * Resolve the configured POS order status for a given payment gateway.