* Add (failing) test for deleted coupon amounts in reporting.

* Always sync an ID into the order-coupon lookup table.

* Don't remove coupon data from order analytics when the coupon is deleted.

* Fix coupon ID lookup from order item metadata.

* Allow negative coupon_id in the lookup table.

Representing deleted coupons.

* Handle deleted coupons when gathering extended info.

* Add more tests for deleted coupon handling.

* Handle deleted coupons in coupons report table.

* Fix lint errors.
This commit is contained in:
Jeff Stieler 2020-06-25 08:51:17 -04:00 committed by GitHub
parent 774fd910c2
commit a6195efad6
5 changed files with 378 additions and 90 deletions

View File

@ -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 = (
<Link href={ couponUrl } type="wc-admin">
{ code }
</Link>
);
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 = (
<Link href={ ordersUrl } type="wc-admin">
{ formatValue(
getCurrencyConfig(),
'number',
ordersCount
) }
</Link>
);
const couponLink =
couponUrl === null ? (
code
) : (
<Link href={ couponUrl } type="wc-admin">
{ code }
</Link>
);
const ordersUrl =
couponId > 0
? getNewPath( persistedQuery, '/analytics/orders', {
filter: 'advanced',
coupon_includes: couponId,
} )
: null;
const ordersLink =
ordersUrl === null ? (
ordersCount
) : (
<Link href={ ordersUrl } type="wc-admin">
{ formatValue(
getCurrencyConfig(),
'number',
ordersCount
) }
</Link>
);
return [
{
@ -133,11 +142,13 @@ class CouponsReportTable extends Component {
value: getCurrencyFormatDecimal( amount ),
},
{
display: (
display: dateCreated ? (
<Date
date={ dateCreated }
visibleFormat={ dateFormat }
/>
) : (
__( '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() {

View File

@ -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.
*

View File

@ -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),

View File

@ -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() );
}
}

View File

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