get_customer_id() > 0; } /** * When the period starts for an order: completion (our best proxy for * delivery), or the order date. * * @param \WC_Order $order Order. * @param string $type return | withdrawal. * @return int|null Timestamp, or null when the period hasn't started (not yet completed). */ public static function period_start( \WC_Order $order, $type ) { $basis = 'withdrawal' === $type ? Settings::get( 'withdrawal_start' ) : Settings::get( 'window_start' ); if ( 'completed' === $basis ) { $completed = $order->get_date_completed(); return $completed ? $completed->getTimestamp() : null; } $created = $order->get_date_created(); return $created ? $created->getTimestamp() : time(); } /** * Deadline (end of the last day, site timezone). * * @param \WC_Order $order Order. * @param string $type return | withdrawal. * @return int|null Timestamp, or null when open-ended for now (not delivered yet). */ public static function deadline( \WC_Order $order, $type ) { $start = self::period_start( $order, $type ); if ( null === $start ) { return null; } if ( 'withdrawal' === $type ) { // Extra delivery days only make sense when counting from completion. $grace = 'completed' === Settings::get( 'withdrawal_start' ) ? (int) Settings::get( 'withdrawal_grace_days' ) : 0; $days = (int) Settings::get( 'withdrawal_days' ) + $grace; } else { $days = (int) Settings::get( 'return_days' ); } $end = ( new \DateTimeImmutable( '@' . $start ) )->setTimezone( wp_timezone() )->modify( '+' . $days . ' days' )->setTime( 23, 59, 59 ); /** * Filters the last moment a return or withdrawal can be requested. * * @param int $deadline Timestamp. * @param \WC_Order $order Order. * @param string $type return | withdrawal. */ return (int) apply_filters( 'rtkt_deadline', $end->getTimestamp(), $order, $type ); } /** * Order statuses that allow a request of this type. * * @param string $type return | withdrawal. * @return string[] Without the wc- prefix. */ public static function statuses( $type ) { return Settings::lines( 'withdrawal' === $type ? 'withdrawal_statuses' : 'return_statuses' ); } /** * Can this order get a request of this type at all? * * Returns [ ok, code, deadline, outside ] where code explains a refusal: * disabled | status | window | nothing_left, and `outside` marks a * withdrawal accepted after the period (the "warn" setting). * * @param \WC_Order $order Order. * @param string $type return | withdrawal. * @return array */ public static function check( \WC_Order $order, $type ) { $result = array( 'ok' => false, 'code' => '', 'deadline' => null, 'outside' => false, ); if ( ! self::enabled( $type ) ) { $result['code'] = 'disabled'; return $result; } if ( $order instanceof \WC_Order_Refund || ! in_array( $order->get_status(), self::statuses( $type ), true ) ) { $result['code'] = 'status'; return $result; } $deadline = self::deadline( $order, $type ); $result['deadline'] = $deadline; if ( null !== $deadline && time() > $deadline ) { if ( 'withdrawal' === $type && 'warn' === Settings::get( 'withdrawal_outside' ) ) { // The store can't know the real delivery date, so the statement // is accepted and flagged for a human to judge. $result['outside'] = true; } else { $result['code'] = 'window'; return $result; } } if ( 'withdrawal' === $type && Requests::has_full_withdrawal( $order->get_id() ) ) { $result['code'] = 'nothing_left'; return $result; } $any = false; foreach ( self::items( $order, $type ) as $line ) { if ( $line['available'] > 0 && ! $line['excluded'] ) { $any = true; break; } } if ( ! $any ) { $result['code'] = 'nothing_left'; return $result; } $result['ok'] = true; /** * Filters whether an order can get a return or withdrawal request. * * @param array $result [ ok, code, deadline, outside ]. * @param \WC_Order $order Order. * @param string $type return | withdrawal. */ return (array) apply_filters( 'rtkt_order_eligibility', $result, $order, $type ); } /** * Customer-facing explanation for a refusal code. * * @param string $code Code from check(). * @param string $type return | withdrawal. * @return string */ public static function message( $code, $type = 'return' ) { $withdrawal = 'withdrawal' === $type; switch ( $code ) { case 'disabled': return $withdrawal ? __( 'Online withdrawal is not available for this store.', 'returnkit' ) : __( 'Online returns are not available for this store.', 'returnkit' ); case 'status': return $withdrawal ? __( 'This order can\'t be withdrawn from online in its current status. Please contact us.', 'returnkit' ) : __( 'This order can\'t be returned yet. Returns open once the order is complete.', 'returnkit' ); case 'window': return $withdrawal ? __( 'The withdrawal period for this order has ended.', 'returnkit' ) : __( 'The return period for this order has ended.', 'returnkit' ); case 'guest': return __( 'Please contact us to return items from this order.', 'returnkit' ); case 'nothing_left': return $withdrawal ? __( 'A withdrawal has already been received for everything in this order.', 'returnkit' ) : __( 'Every item in this order has already been returned or can\'t be returned.', 'returnkit' ); } return __( 'This order isn\'t eligible.', 'returnkit' ); } /** * The order's lines with what's left to return. * * Each line: item_id, item (WC_Order_Item_Product), product (or null), * name, ordered, refunded, held, available, unit_value, excluded, note. * * @param \WC_Order $order Order. * @param string $type return | withdrawal. * @param int $exclude_id Request to leave out of the held count. * @return array[] */ public static function items( \WC_Order $order, $type, $exclude_id = 0 ) { $held = Requests::held_quantities( $order->get_id(), $exclude_id ); $out = array(); foreach ( $order->get_items( 'line_item' ) as $item_id => $item ) { if ( ! $item instanceof \WC_Order_Item_Product ) { continue; } $ordered = (int) $item->get_quantity(); $refunded = absint( $order->get_qty_refunded_for_item( $item_id ) ); $in_req = isset( $held[ $item_id ] ) ? $held[ $item_id ] : 0; $product = $item->get_product(); $excluded = self::excluded_reason( $item, $product, $type ); $out[] = array( 'item_id' => (int) $item_id, 'item' => $item, 'product' => $product ? $product : null, 'name' => $item->get_name(), 'ordered' => $ordered, 'refunded' => $refunded, 'held' => $in_req, 'available' => max( 0, $ordered - $refunded - $in_req ), 'unit_value' => self::unit_value( $order, $item ), 'excluded' => '' !== $excluded, 'note' => $excluded, ); } return $out; } /** * What one unit of a line cost the customer: line total plus its tax, * after discounts, divided by the quantity. This is the most a refund * for that unit can be. * * @param \WC_Order $order Order. * @param \WC_Order_Item_Product $item Line. * @return float */ public static function unit_value( \WC_Order $order, \WC_Order_Item_Product $item ) { $qty = max( 1, (int) $item->get_quantity() ); return round( ( (float) $item->get_total() + (float) $item->get_total_tax() ) / $qty, wc_get_price_decimals() + 2 ); } /** * Why a line can't be returned/withdrawn, or '' when it can. * * @param \WC_Order_Item_Product $item Line. * @param \WC_Product|false|null $product Product (may be deleted). * @param string $type return | withdrawal. * @return string */ public static function excluded_reason( \WC_Order_Item_Product $item, $product, $type ) { $reason = ''; $parent = $product && $product->get_parent_id() ? wc_get_product( $product->get_parent_id() ) : $product; if ( 'withdrawal' === $type ) { if ( $parent && 'yes' === $parent->get_meta( self::META_NO_WITHDRAWAL ) ) { $reason = __( 'Excluded from the right of withdrawal (for example sealed hygiene goods or made-to-order items).', 'returnkit' ); } elseif ( $parent && array_intersect( self::category_ids( $parent ), Settings::ids( 'withdrawal_exclude_cats' ) ) ) { $reason = __( 'Excluded from the right of withdrawal (for example sealed hygiene goods or made-to-order items).', 'returnkit' ); } } elseif ( $parent && 'yes' === $parent->get_meta( self::META_NO_RETURN ) ) { $reason = __( 'This item can\'t be returned.', 'returnkit' ); } elseif ( $parent && array_intersect( self::category_ids( $parent ), Settings::ids( 'exclude_categories' ) ) ) { $reason = __( 'Items in this category can\'t be returned.', 'returnkit' ); } elseif ( $product && Settings::get( 'exclude_virtual' ) && ( $product->is_virtual() || $product->is_downloadable() ) ) { $reason = __( 'Digital and virtual items can\'t be returned.', 'returnkit' ); } elseif ( Settings::get( 'exclude_sale' ) && self::bought_on_sale( $item, $product ) ) { $reason = __( 'Sale items can\'t be returned.', 'returnkit' ); } /** * Filters why an order line can't be returned or withdrawn ('' = it can). * * @param string $reason Reason shown to the customer. * @param \WC_Order_Item_Product $item Line. * @param \WC_Product|null $product Product. * @param string $type return | withdrawal. */ return (string) apply_filters( 'rtkt_item_excluded', $reason, $item, $product ? $product : null, $type ); } /** * Category IDs of a product (parents of a variation are passed in). * * @param \WC_Product $product Product. * @return int[] */ private static function category_ids( $product ) { return array_map( 'absint', (array) $product->get_category_ids() ); } /** * Whether a line was bought below the product's regular price. * * @param \WC_Order_Item_Product $item Line. * @param \WC_Product|false|null $product Product. * @return bool */ private static function bought_on_sale( \WC_Order_Item_Product $item, $product ) { if ( ! $product ) { return false; } $regular = (float) $product->get_regular_price(); $qty = max( 1, (int) $item->get_quantity() ); $paid = (float) $item->get_subtotal() / $qty; // Compare before discounts: a coupon doesn't make it a sale item. return $regular > 0 && $paid + 0.005 < (float) wc_get_price_excluding_tax( $product, array( 'price' => $regular ) ); } /** * Orders a customer can still return or withdraw from, newest first. * * @param int $user_id User ID. * @param string $type return | withdrawal. * @param int $limit Orders to look at. * @return \WC_Order[] */ public static function customer_orders( $user_id, $type, $limit = 50 ) { if ( ! $user_id || ! self::enabled( $type ) ) { return array(); } $orders = wc_get_orders( array( 'customer_id' => (int) $user_id, 'status' => array_map( static function ( $s ) { return 'wc-' . $s; }, self::statuses( $type ) ), 'limit' => $limit, 'orderby' => 'date', 'order' => 'DESC', 'type' => 'shop_order', ) ); $out = array(); Requests::prime( array_map( static function ( $o ) { return $o instanceof \WC_Order ? $o->get_id() : 0; }, $orders ) ); foreach ( $orders as $order ) { if ( $order instanceof \WC_Order && self::check( $order, $type )['ok'] ) { $out[] = $order; } } return $out; } }