PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.17
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.17
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 +0 -358 1.10.24 → 1.10.17 View file →
@@ -16,9 +16,8 @@
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;
21 20 use WC_Discounts;
22 21 use WC_Product;
23 22 use WC_Product_Simple;
24 23 use WC_Tax;
@@ -62,13 +61,8 @@
62 61 add_filter( 'woocommerce_payment_complete_order_status', array( $this, 'payment_complete_order_status' ), 10, 3 );
63 62 add_filter( 'woocommerce_bacs_process_payment_order_status', array( $this, 'offline_process_payment_order_status' ), 10, 2 );
64 63 add_filter( 'woocommerce_cheque_process_payment_order_status', array( $this, 'offline_process_payment_order_status' ), 10, 2 );
65 64 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 );
71 65 add_filter( 'woocommerce_hidden_order_itemmeta', array( $this, 'hidden_order_itemmeta' ) );
72 66 add_filter( 'woocommerce_order_item_product', array( $this, 'order_item_product' ), 10, 2 );
73 67 add_filter( 'woocommerce_order_get_tax_location', array( $this, 'get_tax_location' ), 10, 2 );
74 68 add_action( 'woocommerce_order_item_after_calculate_taxes', array( $this, 'order_item_after_calculate_taxes' ) );
@@ -219,360 +213,8 @@
219 213
220 214 return \in_array( $normalized_status, $valid_statuses, true )
221 215 ? $normalized_status
222 216 : $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 - : '';
575 217 }
576 218
577 219 /**
578 220 * Resolve the configured POS order status for a given payment gateway.