[ qty, reason, exchange_product_id ]. * @param array $args name, email, note, source, resolution, scope (full|items), * statement (withdrawal text), order_number (as typed), flags[]. * @return Request|\WP_Error */ public static function create( $order, $type, array $lines, array $args = array() ) { $type = in_array( $type, array( 'return', 'exchange', 'withdrawal' ), true ) ? $type : 'return'; $args = wp_parse_args( $args, array( 'name' => '', 'email' => '', 'note' => '', 'source' => 'account', 'resolution' => 'refund', 'scope' => 'items', 'statement' => '', 'order_number' => '', 'flags' => array(), ) ); if ( ! $order instanceof \WC_Order ) { if ( 'withdrawal' !== $type ) { return new \WP_Error( 'rtkt_no_order', __( 'We couldn\'t find that order.', 'returnkit' ) ); } return self::create_unmatched_withdrawal( $args ); } $lock = 'rtkt_order_' . $order->get_id(); if ( ! self::lock( $lock ) ) { return new \WP_Error( 'rtkt_busy', __( 'Another request for this order is being saved. Please try again in a moment.', 'returnkit' ) ); } try { // Another request may have claimed items since this page loaded. Requests::flush(); return self::create_locked( $order, $type, $lines, $args ); } finally { self::unlock( $lock ); } } /** * Create while holding the order lock. * * @param \WC_Order $order Order. * @param string $type Type. * @param array $lines Lines. * @param array $args Args. * @return Request|\WP_Error */ private static function create_locked( \WC_Order $order, $type, array $lines, array $args ) { $rule_type = 'withdrawal' === $type ? 'withdrawal' : 'return'; $flags = array_map( 'sanitize_key', (array) $args['flags'] ); if ( 'admin' !== $args['source'] ) { $check = Eligibility::check( $order, $rule_type ); if ( ! $check['ok'] ) { return new \WP_Error( 'rtkt_not_eligible', Eligibility::message( $check['code'], $rule_type ) ); } if ( $check['outside'] ) { $flags[] = 'outside_window'; } } $available = array(); foreach ( Eligibility::items( $order, $rule_type ) as $line ) { $available[ $line['item_id'] ] = $line; } $reasons = Reasons::all(); $scope = 'withdrawal' === $type && 'full' === $args['scope'] ? 'full' : 'items'; $items = array(); if ( 'full' === $scope ) { // Withdrawal from the whole contract: everything still open. foreach ( $available as $line ) { if ( $line['available'] > 0 && ! $line['excluded'] ) { $items[] = self::item_row( $line, $line['available'], 'withdrawal', array() ); } } } else { foreach ( $lines as $item_id => $wanted ) { $item_id = absint( $item_id ); $qty = isset( $wanted['qty'] ) ? absint( $wanted['qty'] ) : 0; if ( ! $qty ) { continue; } if ( ! isset( $available[ $item_id ] ) ) { return new \WP_Error( 'rtkt_bad_item', __( 'One of the selected items isn\'t part of this order.', 'returnkit' ) ); } $line = $available[ $item_id ]; if ( $line['excluded'] && 'admin' !== $args['source'] ) { /* translators: %s: product name. */ return new \WP_Error( 'rtkt_excluded', sprintf( __( '%s can\'t be returned.', 'returnkit' ), $line['name'] ) ); } if ( $qty > $line['available'] ) { /* translators: 1: product name, 2: quantity left. */ return new \WP_Error( 'rtkt_qty', sprintf( __( 'Only %2$d of %1$s can still be returned.', 'returnkit' ), $line['name'], $line['available'] ) ); } if ( 'withdrawal' === $type ) { // A withdrawal needs no reason; one given voluntarily is kept. $reason = isset( $wanted['reason'] ) && isset( $reasons[ $wanted['reason'] ] ) ? $wanted['reason'] : 'withdrawal'; } else { $reason = isset( $wanted['reason'] ) ? sanitize_title( (string) $wanted['reason'] ) : ''; if ( '' !== $reason && ! isset( $reasons[ $reason ] ) ) { $reason = ''; } if ( '' === $reason && Settings::get( 'require_reason' ) && 'admin' !== $args['source'] ) { /* translators: %s: product name. */ return new \WP_Error( 'rtkt_reason', sprintf( __( 'Please choose a reason for returning %s.', 'returnkit' ), $line['name'] ) ); } } $items[] = self::item_row( $line, $qty, $reason, $wanted ); } } // A withdrawal from the general form names the items in words; staff // match them to order lines. The statement itself is what counts. $described = 'withdrawal' === $type && 'items' === $scope && '' !== trim( (string) $args['statement'] ); if ( ! $items && ! $described ) { return new \WP_Error( 'rtkt_no_items', 'withdrawal' === $type ? __( 'Please choose at least one item to withdraw from.', 'returnkit' ) : __( 'Please choose at least one item to return.', 'returnkit' ) ); } /** * Filters the items of a new request before it is saved (Pro uses this * to validate and price exchanges). Return a WP_Error to refuse. * * @param array[] $items Item rows. * @param \WC_Order $order Order. * @param string $type Request type. * @param array $args Request arguments. */ $items = apply_filters( 'rtkt_request_items', $items, $order, $type, $args ); if ( is_wp_error( $items ) ) { return $items; } $total = 0.0; foreach ( $items as $item ) { $total += $item['qty'] * $item['unit_value']; } $email = strtolower( sanitize_email( $args['email'] ) ); if ( '' === $email ) { $email = strtolower( (string) $order->get_billing_email() ); } if ( $order->get_billing_email() && strtolower( $order->get_billing_email() ) !== $email ) { $flags[] = 'email_mismatch'; } $name = trim( (string) $args['name'] ); if ( '' === $name ) { $name = trim( $order->get_formatted_billing_full_name() ); } $resolution = self::resolution( $type, $args['resolution'] ); /** * Filters the flags of a new request (Pro adds frequent-returner and * CardFence flags). * * @param string[] $flags Flags. * @param \WC_Order $order Order. * @param string $email Customer email. */ $flags = array_unique( array_filter( (array) apply_filters( 'rtkt_new_request_flags', $flags, $order, $email ) ) ); $submitted = time(); $meta = array( 'scope' => $scope, 'submitted_at' => $submitted, 'secret' => wp_generate_password( 24, false, false ), ); if ( 'withdrawal' === $type ) { $meta['statement'] = self::statement( $order, $name, $email, $scope, $items, $args, $submitted ); } $id = Requests::insert( array( 'order_id' => $order->get_id(), 'customer_id' => (int) $order->get_customer_id(), 'email' => $email, 'name' => $name, 'type' => $type, 'status' => 'pending', 'resolution' => $resolution, 'note' => $args['note'], 'source' => $args['source'], 'currency' => $order->get_currency(), 'total' => round( $total, wc_get_price_decimals() ), 'flags' => implode( ',', $flags ), 'ip' => self::ip(), 'meta' => $meta, ), $items ); if ( ! $id ) { return new \WP_Error( 'rtkt_db', __( 'Your request could not be saved. Please try again or contact us.', 'returnkit' ) ); } $request = Requests::get( $id ); self::log_created( $request, $args['source'] ); self::order_note( $order, $request ); /** * A request was created (emails, automatic rules and flags hook in here). * * @param Request $request Request. */ do_action( 'rtkt_request_created', $request ); return Requests::get( $id ); } /** * A withdrawal statement that matched no order: still recorded and * acknowledged (the consumer's statement is valid whether or not they * typed the order number right), for staff to match by hand. * * @param array $args Args. * @return Request|\WP_Error */ private static function create_unmatched_withdrawal( array $args ) { $email = strtolower( sanitize_email( $args['email'] ) ); if ( ! is_email( $email ) ) { return new \WP_Error( 'rtkt_email', __( 'Please enter a valid email address.', 'returnkit' ) ); } $submitted = time(); $meta = array( 'scope' => 'full' === $args['scope'] ? 'full' : 'items', 'submitted_at' => $submitted, 'secret' => wp_generate_password( 24, false, false ), 'statement' => self::statement( null, $args['name'], $email, $args['scope'], array(), $args, $submitted ), ); $id = Requests::insert( array( 'order_id' => 0, 'email' => $email, 'name' => $args['name'], 'type' => 'withdrawal', 'status' => 'pending', 'resolution' => 'refund', 'note' => $args['note'], 'source' => $args['source'], 'currency' => get_woocommerce_currency(), 'flags' => 'unmatched', 'ip' => self::ip(), 'meta' => $meta, ), array() ); if ( ! $id ) { return new \WP_Error( 'rtkt_db', __( 'Your request could not be saved. Please try again or contact us.', 'returnkit' ) ); } $request = Requests::get( $id ); self::log_created( $request, $args['source'] ); do_action( 'rtkt_request_created', $request ); return Requests::get( $id ); } /** * One item row from an eligibility line. * * @param array $line Eligibility line. * @param int $qty Quantity. * @param string $reason Reason key. * @param array $wanted Posted data for the line (exchange choice). * @return array */ private static function item_row( array $line, $qty, $reason, array $wanted ) { $item = $line['item']; return array( 'order_item_id' => $line['item_id'], 'product_id' => (int) $item->get_product_id(), 'variation_id' => (int) $item->get_variation_id(), 'name' => $line['name'], 'qty' => (int) $qty, 'unit_value' => (float) $line['unit_value'], 'reason' => $reason, 'exchange_product_id' => isset( $wanted['exchange_product_id'] ) ? absint( $wanted['exchange_product_id'] ) : 0, ); } /** * Resolution allowed for the type and plan. * * @param string $type Request type. * @param string $wanted Requested resolution. * @return string */ private static function resolution( $type, $wanted ) { if ( 'exchange' === $type ) { return 'exchange'; } if ( 'withdrawal' === $type ) { // The law requires reimbursement; credit only if the consumer picks it. return 'credit' === $wanted && Settings::get( 'credit_enabled' ) ? 'credit' : 'refund'; } return 'credit' === $wanted && Settings::get( 'credit_enabled' ) ? 'credit' : 'refund'; } /** * The withdrawal statement as submitted, frozen for the record and the * acknowledgement email. * * @param \WC_Order|null $order Order. * @param string $name Name. * @param string $email Email. * @param string $scope full | items. * @param array[] $items Item rows. * @param array $args Args. * @param int $submitted Timestamp. * @return array */ private static function statement( $order, $name, $email, $scope, array $items, array $args, $submitted ) { $lines = array(); foreach ( $items as $item ) { $lines[] = array( 'name' => $item['name'], 'qty' => (int) $item['qty'], ); } return array( 'name' => (string) $name, 'email' => (string) $email, 'order_number' => $order ? (string) $order->get_order_number() : sanitize_text_field( (string) $args['order_number'] ), 'scope' => 'full' === $scope ? 'full' : 'items', 'items' => $lines, 'details' => sanitize_textarea_field( (string) $args['statement'] ), 'note' => sanitize_textarea_field( (string) $args['note'] ), 'submitted_at' => (int) $submitted, 'submitted' => gmdate( 'c', (int) $submitted ), ); } /** * Log the creation. * * @param Request $request Request. * @param string $source Source. * @return void */ private static function log_created( Request $request, $source ) { $actor = 'admin' === $source ? 'admin' : 'customer'; if ( $request->is_withdrawal() ) { /* translators: %s: date and time with timezone. */ $msg = sprintf( __( 'Withdrawal statement received on %s.', 'returnkit' ), Labels::precise( (int) $request->meta( 'submitted_at' ) ) ); Log::add( $request->id(), 'created', $msg, $actor, array( 'statement' => $request->meta( 'statement' ), 'ip' => $request->get( 'ip' ) ) ); } elseif ( 'admin' === $source ) { Log::add( $request->id(), 'created', __( 'Request created by staff.', 'returnkit' ), $actor ); } else { /* translators: %s: where the request was made. */ Log::add( $request->id(), 'created', sprintf( __( 'Request submitted via %s.', 'returnkit' ), Labels::source( $source ) ), $actor, array( 'ip' => $request->get( 'ip' ) ) ); } foreach ( $request->flags() as $flag ) { /* translators: %s: flag label. */ Log::add( $request->id(), 'flagged', sprintf( __( 'Flagged: %s', 'returnkit' ), Labels::flag( $flag ) ), 'system' ); } } /** * Note on the order. * * @param \WC_Order $order Order. * @param Request $request Request. * @return void */ private static function order_note( \WC_Order $order, Request $request ) { $note = $request->is_withdrawal() /* translators: 1: request number, 2: date and time. */ ? sprintf( __( 'ReturnKit: withdrawal %1$s received on %2$s.', 'returnkit' ), $request->number(), Labels::precise( (int) $request->meta( 'submitted_at' ) ) ) /* translators: 1: request type, 2: request number. */ : sprintf( __( 'ReturnKit: %1$s request %2$s submitted.', 'returnkit' ), Labels::type_lower( $request->type() ), $request->number() ); $order->add_order_note( $note ); } /** * Client IP for the record, anonymized (last octet zeroed) unless the * store opts out. * * @return string */ public static function ip() { $ip = \ReturnKit\Frontend\AntiSpam::client_ip(); if ( '' === $ip ) { return ''; } return Settings::get( 'anonymize_ip' ) ? wp_privacy_anonymize_ip( $ip ) : $ip; } /** * Take a named MySQL lock (released at the end of the request anyway). * * @param string $name Lock name. * @return bool */ public static function lock( $name ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery return '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 5)', $wpdb->prefix . $name ) ); } /** * Release a named lock. * * @param string $name Lock name. * @return void */ public static function unlock( $name ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $wpdb->prefix . $name ) ); } }