470 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			470 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
| <?php
 | |
| /**
 | |
|  * Order Refund Functions
 | |
|  *
 | |
|  * @package     EDD
 | |
|  * @subpackage  Orders
 | |
|  * @copyright   Copyright (c) 2018, Easy Digital Downloads, LLC
 | |
|  * @license     http://opensource.org/licenses/gpl-2.0.php GNU Public License
 | |
|  * @since       3.0
 | |
|  */
 | |
| 
 | |
| use EDD\Orders\Refund_Validator;
 | |
| 
 | |
| // Exit if accessed directly
 | |
| defined( 'ABSPATH' ) || exit;
 | |
| 
 | |
| /**
 | |
|  * Check order can be refunded.
 | |
|  *
 | |
|  * @since 3.0
 | |
|  *
 | |
|  * @param int $order_id Order ID.
 | |
|  * @return bool True if refundable, false otherwise.
 | |
|  */
 | |
| function edd_is_order_refundable( $order_id = 0 ) {
 | |
| 	global $wpdb;
 | |
| 
 | |
| 	// 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;
 | |
| 	}
 | |
| 
 | |
| 	// Only orders with a supported status can be refunded.
 | |
| 	if ( ! in_array( $order->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;
 | |
| }
 |