[ qty, reason, exchange_product_id ]. * * @return array */ private static function posted_lines() { $raw = isset( self::$old['items'] ) && is_array( self::$old['items'] ) ? self::$old['items'] : array(); $lines = array(); foreach ( $raw as $item_id => $line ) { if ( ! is_array( $line ) ) { continue; } $lines[ absint( $item_id ) ] = array( 'qty' => isset( $line['qty'] ) ? absint( $line['qty'] ) : 0, 'reason' => isset( $line['reason'] ) ? sanitize_title( (string) $line['reason'] ) : '', 'exchange_product_id' => isset( $line['exchange'] ) ? absint( $line['exchange'] ) : 0, ); } return $lines; } /** * Verify the visitor may submit for this order. Logged-in owners need a * valid nonce; guests the order key plus the anti-spam checks. * * @param \WC_Order|null $order Order. * @param string $nonce Nonce action. * @param string $bucket Rate-limit bucket for guests. * @return string '' when allowed, otherwise an error message. */ private static function authorize_order( $order, $nonce, $bucket ) { $key = self::field( 'rtkt_key' ); if ( ! $order instanceof \WC_Order ) { return __( 'This link is not valid for your account. Please open it from your order email, or log in.', 'returnkit' ); } if ( '' !== $key ) { // A key must be the right one on its own: never fall back to the // logged-in owner, or a forged form could act for them (CSRF). if ( ! hash_equals( (string) $order->get_order_key(), $key ) ) { return __( 'This link is not valid. Please open it from your order email, or log in.', 'returnkit' ); } return AntiSpam::check( $bucket ); } if ( ! Access::can_use_order( $order, '' ) ) { return __( 'This link is not valid for your account. Please open it from your order email, or log in.', 'returnkit' ); } return wp_verify_nonce( self::field( '_wpnonce' ), $nonce ) ? '' : __( 'Your session expired. Please try again.', 'returnkit' ); } /** * Return / exchange request. * * @return void */ private static function submit_return() { $order = wc_get_order( absint( self::field( 'rtkt_order' ) ) ); $error = self::authorize_order( $order, 'rtkt_return_' . absint( self::field( 'rtkt_order' ) ), 'return' ); if ( '' !== $error ) { Template::notice( $error ); return; } if ( ! Eligibility::guest_allowed( $order ) ) { Template::notice( __( 'Please contact us to return items from this order.', 'returnkit' ) ); return; } $lines = self::posted_lines(); $resolution = sanitize_key( self::field( 'resolution', 'refund' ) ); $type = 'return'; if ( Gate::can( 'exchanges' ) && Settings::get( 'exchanges_enabled' ) ) { foreach ( $lines as $line ) { if ( $line['qty'] && $line['exchange_product_id'] ) { $type = 'exchange'; $resolution = 'exchange'; break; } } } $request = Service::create( $order, $type, $lines, array( 'note' => sanitize_textarea_field( self::field( 'note' ) ), 'source' => self::field( 'rtkt_key' ) ? 'guest' : 'account', 'resolution' => $resolution, ) ); if ( is_wp_error( $request ) ) { Template::notice( $request->get_error_message() ); return; } self::redirect( add_query_arg( 'rtkt_notice', 'created', Access::request_url( $request ) ) ); } /** * Withdrawal: review step (show what will be sent) or confirm step (send). * * @param bool $confirm Confirm step. * @return void */ private static function withdraw( $confirm ) { if ( ! Eligibility::enabled( 'withdrawal' ) ) { Template::notice( Eligibility::message( 'disabled', 'withdrawal' ) ); return; } $order_id = absint( self::field( 'rtkt_order' ) ); $order = $order_id ? wc_get_order( $order_id ) : null; $scope = 'full' === self::field( 'scope' ) ? 'full' : 'items'; if ( $order_id ) { $error = self::authorize_order( $order, 'rtkt_withdraw_' . $order_id, $confirm ? 'withdrawal' : 'withdrawal_review' ); } else { $error = AntiSpam::check( $confirm ? 'withdrawal' : 'withdrawal_review' ); } if ( '' !== $error ) { Template::notice( $error ); return; } $args = array( // Not "name": WordPress reads that query var from POST data too (it's the post slug). 'name' => sanitize_text_field( self::field( 'rtkt_name' ) ), 'email' => sanitize_email( self::field( 'email' ) ), 'note' => sanitize_textarea_field( self::field( 'note' ) ), 'statement' => sanitize_textarea_field( self::field( 'statement' ) ), 'order_number' => sanitize_text_field( self::field( 'order_number' ) ), 'scope' => $scope, 'source' => $order ? ( self::field( 'rtkt_key' ) ? 'guest' : 'account' ) : 'form', ); if ( ! $order ) { // The general form: name, email and order number, as the law describes. if ( '' === $args['name'] || ! is_email( $args['email'] ) || '' === $args['order_number'] ) { Template::notice( __( 'Please enter your name, a valid email address and your order number.', 'returnkit' ) ); return; } if ( 'items' === $scope && '' === trim( $args['statement'] ) ) { Template::notice( __( 'Please tell us which items you are withdrawing from, or choose the whole order.', 'returnkit' ) ); return; } $order = self::match_order( $args['order_number'], $args['email'] ); } elseif ( '' === $args['email'] ) { $args['email'] = $order->get_billing_email(); } $general = ! $order_id; $lines = 'items' === $scope && $order && ! $general ? self::posted_lines() : array(); if ( ! $general && 'items' === $scope && ! $lines && '' === trim( $args['statement'] ) ) { Template::notice( __( 'Please choose at least one item to withdraw from.', 'returnkit' ) ); return; } if ( $order ) { $check = Eligibility::check( $order, 'withdrawal' ); if ( ! $check['ok'] ) { if ( ! $general ) { Template::notice( Eligibility::message( $check['code'], 'withdrawal' ) ); return; } // The general form never reveals whether an order number and // email matched: a statement for an order that can't be // withdrawn from online is recorded for staff, like a typo. $order = null; } } if ( ! $confirm ) { self::$review = array( 'order' => $order && $order_id ? $order : null, 'args' => $args, 'lines' => $lines, ); return; } $request = Service::create( $order, 'withdrawal', $lines, $args ); if ( is_wp_error( $request ) ) { Template::notice( $request->get_error_message() ); return; } if ( $general ) { // Same answer whether or not the order matched; the link to the // request goes to the email address, not to this browser. self::redirect( add_query_arg( 'rtkt_notice', 'withdrawn_general', remove_query_arg( array( 'rtkt_general', 'rtkt_notice' ) ) ) ); } self::redirect( add_query_arg( 'rtkt_notice', 'withdrawn', Access::request_url( $request, ! is_user_logged_in() ) ) ); } /** * Find an order by the number and billing email a customer typed. Both * must match; otherwise the statement is recorded unmatched. * * @param string $number Order number as typed. * @param string $email Email. * @return \WC_Order|null */ public static function match_order( $number, $email ) { $number = ltrim( trim( (string) $number ), '#' ); if ( '' === $number ) { return null; } // Same hook WooCommerce's order tracking uses, so sequential-number // plugins map their numbers to IDs. // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce's own hook. $id = absint( apply_filters( 'woocommerce_shortcode_order_tracking_order_id', $number ) ); $order = $id ? wc_get_order( $id ) : null; if ( ! $order instanceof \WC_Order || $order instanceof \WC_Order_Refund ) { return null; } if ( (string) $order->get_order_number() !== $number && (string) $order->get_id() !== $number ) { return null; } return strtolower( (string) $order->get_billing_email() ) === strtolower( trim( (string) $email ) ) ? $order : null; } /** * Guest look-up: email a link when the order number and email match. The * answer on screen is the same either way. * * @return void */ private static function lookup() { $error = AntiSpam::check( 'lookup' ); if ( '' !== $error ) { Template::notice( $error ); return; } $email = sanitize_email( self::field( 'email' ) ); $order = self::match_order( sanitize_text_field( self::field( 'order_number' ) ), $email ); if ( $order && Eligibility::guest_allowed( $order ) ) { /** * Send the guest a link to manage returns for an order. * * @param \WC_Order $order Order. */ do_action( 'rtkt_send_access_link', $order ); } self::redirect( add_query_arg( 'rtkt_notice', 'sent', remove_query_arg( array( 'rtkt_order', 'rtkt_key', 'rtkt_notice' ) ) ) ); } /** * Customer cancels a pending request. * * @return void */ private static function cancel() { $request = Requests::get( absint( self::field( 'rtkt_request' ) ) ); $token = self::field( 'rtkt_token' ); $valid = $request && ( '' !== $token ? hash_equals( $request->access_token(), $token ) : Access::can_view_request( $request, '' ) ); if ( ! $valid ) { Template::notice( __( 'This link is not valid.', 'returnkit' ) ); return; } if ( '' === $token && ! wp_verify_nonce( self::field( '_wpnonce' ), 'rtkt_cancel_' . $request->id() ) ) { Template::notice( __( 'Your session expired. Please try again.', 'returnkit' ) ); return; } $result = Workflow::cancel( $request ); if ( is_wp_error( $result ) ) { Template::notice( $result->get_error_message() ); return; } self::redirect( add_query_arg( 'rtkt_notice', 'cancelled', Access::request_url( $result, '' !== $token ) ) ); } /** * Redirect and stop. * * @param string $url URL. * @return void */ private static function redirect( $url ) { wp_safe_redirect( $url ); exit; } }