diff --git a/plugins/woocommerce-admin/client/analytics/report/coupons/table.js b/plugins/woocommerce-admin/client/analytics/report/coupons/table.js
index 8cb91b35abf..11862691fee 100644
--- a/plugins/woocommerce-admin/client/analytics/report/coupons/table.js
+++ b/plugins/woocommerce-admin/client/analytics/report/coupons/table.js
@@ -91,33 +91,42 @@ class CouponsReportTable extends Component {
discount_type: discountType,
} = extendedInfo;
- const couponUrl = getNewPath(
- persistedQuery,
- '/analytics/coupons',
- {
- filter: 'single_coupon',
- coupons: couponId,
- }
- );
- const couponLink = (
-
- { code }
-
- );
+ const couponUrl =
+ couponId > 0
+ ? getNewPath( persistedQuery, '/analytics/coupons', {
+ filter: 'single_coupon',
+ coupons: couponId,
+ } )
+ : null;
- const ordersUrl = getNewPath( persistedQuery, '/analytics/orders', {
- filter: 'advanced',
- coupon_includes: couponId,
- } );
- const ordersLink = (
-
- { formatValue(
- getCurrencyConfig(),
- 'number',
- ordersCount
- ) }
-
- );
+ const couponLink =
+ couponUrl === null ? (
+ code
+ ) : (
+
+ { code }
+
+ );
+
+ const ordersUrl =
+ couponId > 0
+ ? getNewPath( persistedQuery, '/analytics/orders', {
+ filter: 'advanced',
+ coupon_includes: couponId,
+ } )
+ : null;
+ const ordersLink =
+ ordersUrl === null ? (
+ ordersCount
+ ) : (
+
+ { formatValue(
+ getCurrencyConfig(),
+ 'number',
+ ordersCount
+ ) }
+
+ );
return [
{
@@ -133,11 +142,13 @@ class CouponsReportTable extends Component {
value: getCurrencyFormatDecimal( amount ),
},
{
- display: (
+ display: dateCreated ? (
+ ) : (
+ __( 'N/A', 'woocommerce-admin' )
),
value: dateCreated,
},
@@ -200,7 +211,7 @@ class CouponsReportTable extends Component {
fixed_cart: __( 'Fixed cart', 'woocommerce-admin' ),
fixed_product: __( 'Fixed product', 'woocommerce-admin' ),
};
- return couponTypes[ discountType ];
+ return couponTypes[ discountType ] || __( 'N/A', 'woocommerce-admin' );
}
render() {
diff --git a/plugins/woocommerce-admin/src/API/Reports/Coupons/DataStore.php b/plugins/woocommerce-admin/src/API/Reports/Coupons/DataStore.php
index 7383596bae9..ae30f83b726 100644
--- a/plugins/woocommerce-admin/src/API/Reports/Coupons/DataStore.php
+++ b/plugins/woocommerce-admin/src/API/Reports/Coupons/DataStore.php
@@ -69,7 +69,6 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
*/
public static function init() {
add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 5 );
- add_action( 'delete_post', array( __CLASS__, 'delete_coupon' ) );
}
/**
@@ -165,38 +164,50 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$coupon_id = $coupon_datum['coupon_id'];
$coupon = new \WC_Coupon( $coupon_id );
- $gmt_timzone = new \DateTimeZone( 'UTC' );
-
- $date_expires = $coupon->get_date_expires();
- if ( is_a( $date_expires, 'DateTime' ) ) {
- $date_expires = $date_expires->format( TimeInterval::$iso_datetime_format );
- $date_expires_gmt = new \DateTime( $date_expires );
- $date_expires_gmt->setTimezone( $gmt_timzone );
- $date_expires_gmt = $date_expires_gmt->format( TimeInterval::$iso_datetime_format );
+ if ( 0 === $coupon->get_id() ) {
+ // Deleted or otherwise invalid coupon.
+ $extended_info = array(
+ 'code' => __( '(Deleted)', 'woocommerce-admin' ),
+ 'date_created' => '',
+ 'date_created_gmt' => '',
+ 'date_expires' => '',
+ 'date_expires_gmt' => '',
+ 'discount_type' => __( 'N/A', 'woocommerce-admin' ),
+ );
} else {
- $date_expires = '';
- $date_expires_gmt = '';
- }
+ $gmt_timzone = new \DateTimeZone( 'UTC' );
- $date_created = $coupon->get_date_created();
- if ( is_a( $date_created, 'DateTime' ) ) {
- $date_created = $date_created->format( TimeInterval::$iso_datetime_format );
- $date_created_gmt = new \DateTime( $date_created );
- $date_created_gmt->setTimezone( $gmt_timzone );
- $date_created_gmt = $date_created_gmt->format( TimeInterval::$iso_datetime_format );
- } else {
- $date_created = '';
- $date_created_gmt = '';
- }
+ $date_expires = $coupon->get_date_expires();
+ if ( is_a( $date_expires, 'DateTime' ) ) {
+ $date_expires = $date_expires->format( TimeInterval::$iso_datetime_format );
+ $date_expires_gmt = new \DateTime( $date_expires );
+ $date_expires_gmt->setTimezone( $gmt_timzone );
+ $date_expires_gmt = $date_expires_gmt->format( TimeInterval::$iso_datetime_format );
+ } else {
+ $date_expires = '';
+ $date_expires_gmt = '';
+ }
- $extended_info = array(
- 'code' => $coupon->get_code(),
- 'date_created' => $date_created,
- 'date_created_gmt' => $date_created_gmt,
- 'date_expires' => $date_expires,
- 'date_expires_gmt' => $date_expires_gmt,
- 'discount_type' => $coupon->get_discount_type(),
- );
+ $date_created = $coupon->get_date_created();
+ if ( is_a( $date_created, 'DateTime' ) ) {
+ $date_created = $date_created->format( TimeInterval::$iso_datetime_format );
+ $date_created_gmt = new \DateTime( $date_created );
+ $date_created_gmt->setTimezone( $gmt_timzone );
+ $date_created_gmt = $date_created_gmt->format( TimeInterval::$iso_datetime_format );
+ } else {
+ $date_created = '';
+ $date_created_gmt = '';
+ }
+
+ $extended_info = array(
+ 'code' => $coupon->get_code(),
+ 'date_created' => $date_created,
+ 'date_created_gmt' => $date_created_gmt,
+ 'date_expires' => $date_expires,
+ 'date_expires_gmt' => $date_expires_gmt,
+ 'discount_type' => $coupon->get_discount_type(),
+ );
+ }
}
$coupon_data[ $idx ]['extended_info'] = $extended_info;
}
@@ -314,6 +325,28 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
return $data;
}
+ /**
+ * Get coupon ID for an order.
+ *
+ * Tries to get the ID from order item meta, then falls back to a query of published coupons.
+ *
+ * @param \WC_Order_Item_Coupon $coupon_item The coupon order item object.
+ * @return int Coupon ID on success, 0 on failure.
+ */
+ public static function get_coupon_id( \WC_Order_Item_Coupon $coupon_item ) {
+ // First attempt to get coupon ID from order item data.
+ $coupon_data = $coupon_item->get_meta( 'coupon_data', true );
+
+ // Normal checkout orders should have this data.
+ // See: https://github.com/woocommerce/woocommerce/blob/3dc7df7af9f7ca0c0aa34ede74493e856f276abe/includes/abstracts/abstract-wc-order.php#L1206.
+ if ( isset( $coupon_data['id'] ) ) {
+ return $coupon_data['id'];
+ }
+
+ // Try to get the coupon ID using the code.
+ return wc_get_coupon_id_by_code( $coupon_item->get_code() );
+ }
+
/**
* Create or update an an entry in the wc_order_coupon_lookup table for an order.
*
@@ -347,14 +380,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$coupon_items = $order->get_items( 'coupon' );
$coupon_items_count = count( $coupon_items );
$num_updated = 0;
+ $num_deleted = 0;
foreach ( $coupon_items as $coupon_item ) {
- $coupon_id = wc_get_coupon_id_by_code( $coupon_item->get_code() );
+ $coupon_id = self::get_coupon_id( $coupon_item );
unset( $existing_items[ $coupon_id ] );
if ( ! $coupon_id ) {
- $coupon_items_count--;
- continue;
+ // Insert a unique, but obviously invalid ID for this deleted coupon.
+ $num_deleted++;
+ $coupon_id = -1 * $num_deleted;
}
$result = $wpdb->replace(
@@ -422,27 +457,6 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
ReportsCache::invalidate();
}
- /**
- * Deletes the coupon lookup information when a coupon is deleted.
- * This keeps data consistent if it gets resynced at any point.
- *
- * @param int $post_id Post ID.
- */
- public static function delete_coupon( $post_id ) {
- global $wpdb;
-
- if ( 'shop_coupon' !== get_post_type( $post_id ) ) {
- return;
- }
-
- $wpdb->delete(
- self::get_db_table_name(),
- array( 'coupon_id' => $post_id )
- );
-
- ReportsCache::invalidate();
- }
-
/**
* Gets coupons based on the provided arguments.
*
diff --git a/plugins/woocommerce-admin/src/Install.php b/plugins/woocommerce-admin/src/Install.php
index 3242a980b1e..e9e61f9b8d2 100644
--- a/plugins/woocommerce-admin/src/Install.php
+++ b/plugins/woocommerce-admin/src/Install.php
@@ -237,7 +237,7 @@ class Install {
) $collate;
CREATE TABLE {$wpdb->prefix}wc_order_coupon_lookup (
order_id BIGINT UNSIGNED NOT NULL,
- coupon_id BIGINT UNSIGNED NOT NULL,
+ coupon_id BIGINT NOT NULL,
date_created datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
discount_amount double DEFAULT 0 NOT NULL,
PRIMARY KEY (order_id, coupon_id),
diff --git a/plugins/woocommerce-admin/tests/reports/class-wc-tests-reports-coupons-stats.php b/plugins/woocommerce-admin/tests/reports/class-wc-tests-reports-coupons-stats.php
index 646471ea71b..6b946aa0ed9 100644
--- a/plugins/woocommerce-admin/tests/reports/class-wc-tests-reports-coupons-stats.php
+++ b/plugins/woocommerce-admin/tests/reports/class-wc-tests-reports-coupons-stats.php
@@ -7,7 +7,7 @@
use \Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats\DataStore as CouponsStatsDataStore;
use \Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats\Query as CouponsStatsQuery;
-
+
/**
* Class WC_Tests_Reports_Coupons_Stats
*/
@@ -60,8 +60,8 @@ class WC_Tests_Reports_Coupons_Stats extends WC_Unit_Test_Case {
WC_Helper_Queue::run_all_pending();
$data_store = new CouponsStatsDataStore();
- $start_time = date( 'Y-m-d 00:00:00', $order->get_date_created()->getOffsetTimestamp() );
- $end_time = date( 'Y-m-d 23:59:59', $order->get_date_created()->getOffsetTimestamp() );
+ $start_time = gmdate( 'Y-m-d 00:00:00', $order->get_date_created()->getOffsetTimestamp() );
+ $end_time = gmdate( 'Y-m-d 23:59:59', $order->get_date_created()->getOffsetTimestamp() );
$args = array(
'after' => $start_time,
'before' => $end_time,
@@ -104,4 +104,75 @@ class WC_Tests_Reports_Coupons_Stats extends WC_Unit_Test_Case {
$query = new CouponsStatsQuery( $args );
$this->assertEquals( $expected_data, $query->get_data() );
}
+
+ /**
+ * Test that deleted coupon are still included in reports.
+ */
+ public function test_deleted_coupon() {
+ WC_Helper_Reports::reset_stats_dbs();
+
+ // Simple product.
+ $product = new WC_Product_Simple();
+ $product->set_name( 'Test Product' );
+ $product->set_regular_price( 35 );
+ $product->save();
+
+ // Coupons.
+ $coupon_1_amount = 5;
+ $coupon_1 = WC_Helper_Coupon::create_coupon( 'coupon_1' );
+ $coupon_1->set_amount( $coupon_1_amount );
+ $coupon_1->save();
+
+ // Order with 1 coupon.
+ $order = WC_Helper_Order::create_order( 1, $product );
+ $order->set_status( 'completed' );
+ $order->apply_coupon( $coupon_1 );
+ $order->calculate_totals();
+ $order->save();
+
+ // Delete the coupon.
+ $coupon_1->delete( true );
+
+ WC_Helper_Queue::run_all_pending();
+
+ $start_time = gmdate( 'Y-m-d 00:00:00', $order->get_date_created()->getOffsetTimestamp() );
+ $end_time = gmdate( 'Y-m-d 23:59:59', $order->get_date_created()->getOffsetTimestamp() );
+ $args = array(
+ 'after' => $start_time,
+ 'before' => $end_time,
+ 'interval' => 'day',
+ );
+
+ // Test retrieving the stats through the query class.
+ $query = new CouponsStatsQuery( $args );
+ $start_datetime = new DateTime( $start_time );
+ $end_datetime = new DateTime( $end_time );
+ $expected_data = (object) array(
+ 'total' => 1,
+ 'pages' => 1,
+ 'page_no' => 1,
+ 'totals' => (object) array(
+ 'amount' => floatval( $coupon_1_amount ),
+ 'coupons_count' => 1,
+ 'orders_count' => 1,
+ 'segments' => array(),
+ ),
+ 'intervals' => array(
+ array(
+ 'interval' => $start_datetime->format( 'Y-m-d' ),
+ 'date_start' => $start_datetime->format( 'Y-m-d H:i:s' ),
+ 'date_start_gmt' => $start_datetime->format( 'Y-m-d H:i:s' ),
+ 'date_end' => $end_datetime->format( 'Y-m-d H:i:s' ),
+ 'date_end_gmt' => $end_datetime->format( 'Y-m-d H:i:s' ),
+ 'subtotals' => (object) array(
+ 'amount' => floatval( $coupon_1_amount ),
+ 'coupons_count' => 1,
+ 'orders_count' => 1,
+ 'segments' => array(),
+ ),
+ ),
+ ),
+ );
+ $this->assertEquals( $expected_data, $query->get_data() );
+ }
}
diff --git a/plugins/woocommerce-admin/tests/reports/class-wc-tests-reports-coupons.php b/plugins/woocommerce-admin/tests/reports/class-wc-tests-reports-coupons.php
index c628fdb4f9b..b61ea35c8e6 100644
--- a/plugins/woocommerce-admin/tests/reports/class-wc-tests-reports-coupons.php
+++ b/plugins/woocommerce-admin/tests/reports/class-wc-tests-reports-coupons.php
@@ -62,8 +62,8 @@ class WC_Tests_Reports_Coupons extends WC_Unit_Test_Case {
WC_Helper_Queue::run_all_pending();
$data_store = new CouponsDataStore();
- $start_time = date( 'Y-m-d 00:00:00', $order->get_date_created()->getOffsetTimestamp() );
- $end_time = date( 'Y-m-d 23:59:59', $order->get_date_created()->getOffsetTimestamp() );
+ $start_time = gmdate( 'Y-m-d 00:00:00', $order->get_date_created()->getOffsetTimestamp() );
+ $end_time = gmdate( 'Y-m-d 23:59:59', $order->get_date_created()->getOffsetTimestamp() );
$args = array(
'after' => $start_time,
'before' => $end_time,
@@ -288,7 +288,7 @@ class WC_Tests_Reports_Coupons extends WC_Unit_Test_Case {
$coupon_2_response['orders_count'],
$coupon_2_response['amount'],
$coupon_2_response['extended_info']['date_created'],
- $coupon_2_response['extended_info']['date_expires'] ? : 'N/A',
+ $coupon_2_response['extended_info']['date_expires'] ? $coupon_2_response['extended_info']['date_expires'] : 'N/A',
$coupon_2_response['extended_info']['discount_type'],
);
@@ -298,7 +298,7 @@ class WC_Tests_Reports_Coupons extends WC_Unit_Test_Case {
$coupon_1_response['orders_count'],
$coupon_1_response['amount'],
$coupon_1_response['extended_info']['date_created'],
- $coupon_1_response['extended_info']['date_expires'] ? : 'N/A',
+ $coupon_1_response['extended_info']['date_expires'] ? $coupon_1_response['extended_info']['date_expires'] : 'N/A',
$coupon_1_response['extended_info']['discount_type'],
);
@@ -320,4 +320,196 @@ class WC_Tests_Reports_Coupons extends WC_Unit_Test_Case {
$this->assertEquals( 2, $export->get_total_exported() );
$this->assertEquals( $expected_csv, $actual_csv );
}
+
+ /**
+ * Test that calculations and querying works correctly when coupons are deleted.
+ */
+ public function test_deleted_coupons() {
+ WC_Helper_Reports::reset_stats_dbs();
+
+ // Simple product.
+ $product = new WC_Product_Simple();
+ $product->set_name( 'Test Product' );
+ $product->set_regular_price( 25 );
+ $product->save();
+
+ // Coupons.
+ $coupon_1_amount = 3; // by default in create_coupon.
+ $coupon_1 = WC_Helper_Coupon::create_coupon( 'coupon_1' );
+ $coupon_1->set_amount( $coupon_1_amount );
+ $coupon_1->save();
+
+ // Coupon that will be deleted.
+ $coupon_2_amount = 7;
+ $coupon_2 = WC_Helper_Coupon::create_coupon( 'coupon_2' );
+ $coupon_2->set_amount( $coupon_2_amount );
+ $coupon_2->save();
+
+ // Coupon that will be deleted, but order item will have metadata that will preserve its ID.
+ $coupon_3_amount = 1;
+ $coupon_3 = WC_Helper_Coupon::create_coupon( 'coupon_3' );
+ $coupon_3->set_amount( $coupon_3_amount );
+ $coupon_3->save();
+ $coupon_3_id = $coupon_3->get_id();
+
+ // Order with coupons.
+ $order = WC_Helper_Order::create_order( 1, $product );
+ $order->set_status( 'completed' );
+ $order->apply_coupon( $coupon_1 );
+ $order->apply_coupon( $coupon_2 );
+ $order->apply_coupon( $coupon_3 );
+ $order->calculate_totals();
+ $order->save();
+
+ // Add coupon_3 metadata to its order item.
+ $coupon_items = $order->get_items( 'coupon' );
+
+ // This would normally happen at checkout.
+ foreach ( $coupon_items as $coupon_item ) {
+ if ( 'coupon_3' !== $coupon_item->get_code() ) {
+ continue;
+ }
+
+ $coupon_3_data = $coupon_3->get_data();
+ unset( $coupon_3_data['used_by'] );
+ $coupon_item->add_meta_data( 'coupon_data', $coupon_3_data );
+ $coupon_item->save();
+ }
+
+ // Delete the coupons.
+ $coupon_2->delete( true );
+ $coupon_3->delete( true );
+
+ WC_Helper_Queue::run_all_pending();
+
+ $data_store = new CouponsDataStore();
+ $start_time = gmdate( 'Y-m-d 00:00:00', $order->get_date_created()->getOffsetTimestamp() );
+ $end_time = gmdate( 'Y-m-d 23:59:59', $order->get_date_created()->getOffsetTimestamp() );
+ $args = array(
+ 'after' => $start_time,
+ 'before' => $end_time,
+ );
+
+ // Test retrieving the stats through the data store.
+ $coupon_1_response = array(
+ 'coupon_id' => $coupon_1->get_id(),
+ 'amount' => floatval( $coupon_1_amount ),
+ 'orders_count' => 1,
+ 'extended_info' => new ArrayObject(),
+ );
+ $coupon_2_response = array(
+ 'coupon_id' => -1, // coupon_2 was deleted, so a new ID was created.
+ 'amount' => floatval( $coupon_2_amount ),
+ 'orders_count' => 1,
+ 'extended_info' => new ArrayObject(),
+ );
+ $coupon_3_response = array(
+ 'coupon_id' => $coupon_3_id, // coupon_3 was deleted, but has metadata containing the ID.
+ 'amount' => floatval( $coupon_3_amount ),
+ 'orders_count' => 1,
+ 'extended_info' => new ArrayObject(),
+ );
+
+ // Order by coupon id DESC is the default.
+ $data = $data_store->get_data( $args );
+ $expected_data = (object) array(
+ 'total' => 3,
+ 'pages' => 1,
+ 'page_no' => 1,
+ 'data' => array(
+ // Order is 3, 1, 2 since query is sorted by coupon ID descending.
+ 0 => $coupon_3_response,
+ 1 => $coupon_1_response,
+ 2 => $coupon_2_response,
+ ),
+ );
+ $this->assertEquals( $expected_data, $data );
+
+ // Test extended info.
+ $gmt_timezone = new DateTimeZone( 'UTC' );
+ $c1_date_created = $coupon_1->get_date_created();
+ if ( null === $c1_date_created ) {
+ $c1_date_created = '';
+ $c1_date_created_gmt = '';
+ } else {
+ $c1_date_created_gmt = new DateTime( $c1_date_created );
+ $c1_date_created_gmt->setTimezone( $gmt_timezone );
+
+ $c1_date_created = $c1_date_created->format( TimeInterval::$iso_datetime_format );
+ $c1_date_created_gmt = $c1_date_created_gmt->format( TimeInterval::$iso_datetime_format );
+ }
+
+ $c1_date_expires = $coupon_1->get_date_expires();
+ if ( null === $c1_date_expires ) {
+ $c1_date_expires = '';
+ $c1_date_expires_gmt = '';
+ } else {
+ $c1_date_expires_gmt = new DateTime( $c1_date_expires );
+ $c1_date_expires_gmt->setTimezone( $gmt_timezone );
+
+ $c1_date_expires = $c1_date_expires->format( TimeInterval::$iso_datetime_format );
+ $c1_date_expires_gmt = $c1_date_expires_gmt->format( TimeInterval::$iso_datetime_format );
+ }
+
+ $coupon_1_response = array(
+ 'coupon_id' => $coupon_1->get_id(),
+ 'amount' => floatval( $coupon_1_amount ),
+ 'orders_count' => 1,
+ 'extended_info' => array(
+ 'code' => $coupon_1->get_code(),
+ 'date_created' => $c1_date_created,
+ 'date_created_gmt' => $c1_date_created_gmt,
+ 'date_expires' => $c1_date_expires,
+ 'date_expires_gmt' => $c1_date_expires_gmt,
+ 'discount_type' => $coupon_1->get_discount_type(),
+ ),
+ );
+
+ $coupon_2_response = array(
+ 'coupon_id' => -1,
+ 'amount' => floatval( $coupon_2_amount ),
+ 'orders_count' => 1,
+ 'extended_info' => array(
+ 'code' => '(Deleted)',
+ 'date_created' => '',
+ 'date_created_gmt' => '',
+ 'date_expires' => '',
+ 'date_expires_gmt' => '',
+ 'discount_type' => 'N/A',
+ ),
+ );
+
+ $coupon_3_response = array(
+ 'coupon_id' => $coupon_3_id, // coupon_3 was deleted, but has metadata containing the ID.
+ 'amount' => floatval( $coupon_3_amount ),
+ 'orders_count' => 1,
+ 'extended_info' => array(
+ 'code' => '(Deleted)',
+ 'date_created' => '',
+ 'date_created_gmt' => '',
+ 'date_expires' => '',
+ 'date_expires_gmt' => '',
+ 'discount_type' => 'N/A',
+ ),
+ );
+
+ $args = array(
+ 'after' => $start_time,
+ 'before' => $end_time,
+ 'extended_info' => true,
+ );
+ $data = $data_store->get_data( $args );
+ $expected_data = (object) array(
+ 'total' => 3,
+ 'pages' => 1,
+ 'page_no' => 1,
+ 'data' => array(
+ // Order is 3, 1, 2 since query is sorted by coupon ID descending.
+ 0 => $coupon_3_response,
+ 1 => $coupon_1_response,
+ 2 => $coupon_2_response,
+ ),
+ );
+ $this->assertEquals( $expected_data, $data );
+ }
}