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