laipower/wp-content/plugins/easy-digital-downloads/includes/orders/functions/refunds.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;
}