status, edd_get_refundable_order_statuses(), true ) ) { return false; } // Check order hasn't already been refunded. $query = "SELECT COUNT(id) FROM {$wpdb->edd_orders} WHERE parent = %d AND status = '%s'"; $prepare = sprintf( $query, $order_id, esc_sql( 'refunded' ) ); $refunded_order = $wpdb->get_var( $prepare ); // WPCS: unprepared SQL ok. if ( 0 < absint( $refunded_order ) ) { return false; } // Allow overrides. if ( true === edd_is_order_refundable_by_override( $order->id ) ) { return true; } // Outside of Refund window. if ( true === edd_is_order_refund_window_passed( $order->id ) ) { return false; } // If we have reached here, every other check holds so the order is refundable. return true; } /** * Check order is passed its refund window. * * @since 3.0 * * @param int $order_id Order ID. * @return bool True if in window, false otherwise. */ function edd_is_order_refund_window_passed( $order_id = 0 ) { // Bail if no order ID was passed. if ( empty( $order_id ) ) { return false; } $order = edd_get_order( $order_id ); // Bail if order was not found. if ( ! $order ) { return false; } // Refund dates may not have been set retroactively so we need to calculate it manually. if ( empty( $order->date_refundable ) ) { $refund_window = absint( edd_get_option( 'refund_window', 30 ) ); // Refund window is infinite. if ( 0 === $refund_window ) { return true; } else { $date_refundable = \Carbon\Carbon::parse( $order->date_completed, 'UTC' )->setTimezone( edd_get_timezone_id() )->addDays( $refund_window ); } // Parse date using Carbon. } else { $date_refundable = \Carbon\Carbon::parse( $order->date_refundable, 'UTC' )->setTimezone( edd_get_timezone_id() ); } return true === $date_refundable->isPast(); } /** * Check order can be refunded via a capability override. * * @since 3.0 * * @param int $order_id Order ID. * @return bool True if refundable via capability override, false otherwise. */ function edd_is_order_refundable_by_override( $order_id = 0 ) { // Bail if no order ID was passed. if ( empty( $order_id ) ) { return false; } $order = edd_get_order( $order_id ); // Bail if order was not found. if ( ! $order ) { return false; } // Allow certain capabilities to always provide refunds. $caps = array( 'edit_shop_payments' ); /** * Filters the user capabilities that are required for overriding * refundability requirements. * * @since 3.0 * * @param array $caps List of capabilities that can override * refundability. Default `edit_shop_payments`. * @param int $order_id ID of current Order being refunded. */ $caps = apply_filters( 'edd_is_order_refundable_by_override_caps', $caps, $order_id ); $can_override = false; foreach ( $caps as $cap ) { if ( true === current_user_can( $cap ) ) { $can_override = true; break; } } /** * Filters the allowance of refunds on an Order. * * @since 3.0 * * @param bool $can_override If the refundability can be overriden by * the current user. * @param int $order_id ID of current Order being refunded. */ $can_override = apply_filters( 'edd_is_order_refundable_by_override', $can_override, $order_id ); return $can_override; } /** * Refund entire order. * * @since 3.0 * * @param int $order_id Order ID. * @param array|string $order_items { * Optional. Either `all` as a string to refund all order items, or an array of * order item IDs, amounts, and quantities to refund. * * @type int $order_item_id Required. ID of the order item. * @type int $quantity Required. Quantity being refunded. * @type float $subtotal Required. Amount to refund, excluding tax. * @type float $tax Optional. Amount of tax to refund. * } * * @param array|string $adjustments { * Optional. Either `all` as a string to refund all order adjustments, or an array of * order adjustment IDs and amounts to refund. * * @type int $adjustment_id Required. ID of the order adjustment being refunded. * @type float $subtotal Required. Amount to refund, excluding tax. * @type float $tax Required. Amount of tax to refund. * } * * @return int|WP_Error New order ID if successful, WP_Error on failure. */ function edd_refund_order( $order_id, $order_items = 'all', $adjustments = 'all' ) { global $wpdb; // Ensure the order ID is an integer. $order_id = absint( $order_id ); // Fetch order. $order = edd_get_order( $order_id ); if ( ! $order ) { return new WP_Error( 'invalid_order', __( 'Invalid order.', 'easy-digital-downloads' ) ); } if ( false === edd_is_order_refundable( $order_id ) ) { return new WP_Error( 'not_refundable', __( 'Order not refundable.', 'easy-digital-downloads' ) ); } /** * Filter whether refunds should be allowed. * * @since 3.0 * * @param int $order_id Order ID. */ $should_refund = apply_filters( 'edd_should_process_order_refund', true, $order_id ); // Bail if refund is blocked. if ( true !== $should_refund ) { return new WP_Error( 'refund_not_allowed', __( 'Refund not allowed on this order.', 'easy-digital-downloads' ) ); } /** Generate new order number *********************************************/ $last_order = $wpdb->get_row( $wpdb->prepare( "SELECT id, order_number FROM {$wpdb->edd_orders} WHERE parent = %d ORDER BY id DESC LIMIT 1", $order_id ) ); /** * Filter the suffix applied to order numbers for refunds. * * @since 3.0 * * @param string Suffix. */ $refund_suffix = apply_filters( 'edd_order_refund_suffix', '-R-' ); if ( $last_order ) { // Check for order number first. if ( $last_order->order_number && ! empty( $last_order->order_number ) ) { // Order has been previously revised. if ( false !== strpos( $last_order->order_number, $refund_suffix ) ) { $number = $last_order->order_number; ++$number; // First revision to order. } else { $number = $last_order->id . $refund_suffix . '1'; } // Append to ID. } else { $number = $last_order->id . $refund_suffix . '1'; } } else { $number = $order->id . $refund_suffix . '1'; } /** Validate refund amounts *************************************************/ try { $validator = new Refund_Validator( $order, $order_items, $adjustments ); $validator->validate_and_calculate_totals(); } catch( \EDD\Utils\Exceptions\Invalid_Argument $e ) { return new WP_Error( 'refund_validation_error', __( 'Invalid argument. Please check your amounts and try again.', 'easy-digital-downloads' ) ); } catch ( \Exception $e ) { return new WP_Error( 'refund_validation_error', $e->getMessage() ); } /** Insert order **********************************************************/ $order_data = array( 'parent' => $order_id, 'order_number' => $number, 'status' => 'complete', 'type' => 'refund', 'user_id' => $order->user_id, 'customer_id' => $order->customer_id, 'email' => $order->email, 'ip' => $order->ip, 'gateway' => $order->gateway, 'mode' => $order->mode, 'currency' => $order->currency, 'payment_key' => strtolower( md5( uniqid() ) ), 'tax_rate_id' => $order->tax_rate_id, 'subtotal' => edd_negate_amount( $validator->subtotal ), 'tax' => edd_negate_amount( $validator->tax ), 'total' => edd_negate_amount( $validator->total ), ); // Full refund is inserted first to allow for conditional checks to run later // and update the order, but we need an INSERT to be executed to generate a // new order ID. $refund_id = edd_add_order( $order_data ); // If we have tax, but no tax rate, manually save the percentage. $tax_rate_meta = edd_get_order_meta( $order_id, 'tax_rate', true ); if ( $tax_rate_meta ) { edd_update_order_meta( $refund_id, 'tax_rate', $tax_rate_meta ); } /** Insert order items ****************************************************/ // Maintain a mapping of old order item IDs => new for easier lookup when we do fees. $order_item_id_map = array(); foreach ( $validator->get_refunded_order_items() as $order_item ) { $order_item['order_id'] = $refund_id; $new_item_id = edd_add_order_item( $order_item ); if ( ! empty( $order_item['parent'] ) ) { $order_item_id_map[ $order_item['parent'] ] = $new_item_id; } // Update the status on the original order item. if ( ! empty( $order_item['parent'] ) && ! empty( $order_item['original_item_status'] ) ) { edd_update_order_item( $order_item['parent'], array( 'status' => $order_item['original_item_status'] ) ); } } /** Insert order adjustments **********************************************/ foreach ( $validator->get_refunded_adjustments() as $adjustment ) { if ( ! empty( $adjustment['object_type'] ) && 'order' === $adjustment['object_type'] ) { $adjustment['object_id'] = $refund_id; } elseif ( ! empty( $adjustment['object_type'] ) && 'order_item' === $adjustment['object_type'] ) { /* * At this point, `object_id` references an order item which is attached to the * original order record. We need to try to convert this to a _refund_ order item * instead. * * If we can't (such as, if the order item was never refunded), we'll have to * convert the adjustment to be an `order` object type instead. That's because we * _have_ to reference a refund object of some kind. */ $order_item_match_found = false; if ( ! empty( $adjustment['object_id'] ) && ! empty( $order_item_id_map[ $adjustment['object_id'] ] ) ) { // We don't need to convert to an `order` adjustment if we are also refunding the original order item. $adjustment['object_id'] = $order_item_id_map[ $adjustment['object_id'] ]; $order_item_match_found = true; } if ( ! $order_item_match_found ) { $adjustment['object_type'] = 'order'; $adjustment['object_id'] = $refund_id; } } /* * Note: Order item adjustments retain their `object_id` link to the *original* order item -- not the * refunded order item. This isn't ideal, but it's because you could refund an order item fee without * refunding the associated item, in which case there would be no refunded order item to reference. * So we link back to the *original* order item in all cases to be consistent. */ edd_add_order_adjustment( $adjustment ); } // Update order status to `refunded` once refund is complete and if all items are marked as refunded. $all_refunded = true; $remaining_items = edd_count_order_items( array( 'order_id' => $order_id, 'status__not_in' => array( 'refunded' ), ) ); if ( edd_get_order_total( $order_id ) > 0 || $remaining_items > 0 ) { $all_refunded = false; } $order_status = true === $all_refunded ? 'refunded' : 'partially_refunded'; edd_update_order( $order_id, array( 'status' => $order_status ) ); edd_update_order( $refund_id, array( 'date_completed' => date( 'Y-m-d H:i:s' ) ) ); /** * Fires when an order has been refunded. * This hook will trigger the legacy `edd_pre_refund_payment` and `edd_post_refund_payment` * hooks for the time being, but any code using either of those should be updated. * * @since 3.0 * * @param int $order_id Order ID of the original order. * @param int $refund_id ID of the new refund object. * @param bool $all_refunded Whether or not the entire order was refunded. */ do_action( 'edd_refund_order', $order_id, $refund_id, $all_refunded ); return $refund_id; } /** * Queries for order refunds. * * @see \EDD\Database\Queries\Order::__construct() * * @since 3.0 * * @param int $order_id Parent Order. * @return \EDD\Orders\Order[] Array of `Order` objects. */ function edd_get_order_refunds( $order_id = 0 ) { $order_refunds = new \EDD\Database\Queries\Order(); return $order_refunds->query( array( 'type' => 'refund', 'parent' => $order_id, ) ); } /** * Calculate order total. This method is used to calculate the total of an order * by also taking into account any refunds/partial refunds. * * @since 3.0 * * @param int $order_id Order ID. * @return float $total Order total. */ function edd_get_order_total( $order_id ) { global $wpdb; $query = "SELECT SUM(total) FROM {$wpdb->edd_orders} WHERE id = %d OR parent = %d"; $prepare = $wpdb->prepare( $query, $order_id, $order_id ); $total = $wpdb->get_var( $prepare ); // WPCS: unprepared SQL ok. $retval = ( null === $total ) ? 0.00 : floatval( $total ); return $retval; } /** * Calculate order item total. This method is used to calculate the total of an * order item by also taking into account any refunds/partial refunds. * * @since 3.0 * * @param array $order_ids Order IDs. * @param int $product_id Product ID. * * @return float $total Order total. */ function edd_get_order_item_total( $order_ids = array(), $product_id = 0 ) { global $wpdb; // Bail if no order IDs were passed. if ( empty( $order_ids ) ) { return 0; } $query = "SELECT SUM(total) FROM {$wpdb->edd_order_items} WHERE order_id IN (%s) AND product_id = %d"; $ids = join( ',', array_map( 'absint', $order_ids ) ); $prepare = sprintf( $query, $ids, $product_id ); $total = $wpdb->get_var( $prepare ); // WPCS: unprepared SQL ok. $retval = ( null === $total ) ? 0.00 : floatval( $total ); return $retval; }