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;
|
|
}
|