* 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,25 +91,34 @@ class CouponsReportTable extends Component {
discount_type: discountType, discount_type: discountType,
} = extendedInfo; } = extendedInfo;
const couponUrl = getNewPath( const couponUrl =
persistedQuery, couponId > 0
'/analytics/coupons', ? getNewPath( persistedQuery, '/analytics/coupons', {
{
filter: 'single_coupon', filter: 'single_coupon',
coupons: couponId, coupons: couponId,
} } )
); : null;
const couponLink = (
const couponLink =
couponUrl === null ? (
code
) : (
<Link href={ couponUrl } type="wc-admin"> <Link href={ couponUrl } type="wc-admin">
{ code } { code }
</Link> </Link>
); );
const ordersUrl = getNewPath( persistedQuery, '/analytics/orders', { const ordersUrl =
couponId > 0
? getNewPath( persistedQuery, '/analytics/orders', {
filter: 'advanced', filter: 'advanced',
coupon_includes: couponId, coupon_includes: couponId,
} ); } )
const ordersLink = ( : null;
const ordersLink =
ordersUrl === null ? (
ordersCount
) : (
<Link href={ ordersUrl } type="wc-admin"> <Link href={ ordersUrl } type="wc-admin">
{ formatValue( { formatValue(
getCurrencyConfig(), getCurrencyConfig(),
@ -133,11 +142,13 @@ class CouponsReportTable extends Component {
value: getCurrencyFormatDecimal( amount ), value: getCurrencyFormatDecimal( amount ),
}, },
{ {
display: ( display: dateCreated ? (
<Date <Date
date={ dateCreated } date={ dateCreated }
visibleFormat={ dateFormat } visibleFormat={ dateFormat }
/> />
) : (
__( 'N/A', 'woocommerce-admin' )
), ),
value: dateCreated, value: dateCreated,
}, },
@ -200,7 +211,7 @@ class CouponsReportTable extends Component {
fixed_cart: __( 'Fixed cart', 'woocommerce-admin' ), fixed_cart: __( 'Fixed cart', 'woocommerce-admin' ),
fixed_product: __( 'Fixed product', 'woocommerce-admin' ), fixed_product: __( 'Fixed product', 'woocommerce-admin' ),
}; };
return couponTypes[ discountType ]; return couponTypes[ discountType ] || __( 'N/A', 'woocommerce-admin' );
} }
render() { render() {

View File

@ -69,7 +69,6 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
*/ */
public static function init() { public static function init() {
add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 5 ); add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 5 );
add_action( 'delete_post', array( __CLASS__, 'delete_coupon' ) );
} }
/** /**
@ -165,6 +164,17 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$coupon_id = $coupon_datum['coupon_id']; $coupon_id = $coupon_datum['coupon_id'];
$coupon = new \WC_Coupon( $coupon_id ); $coupon = new \WC_Coupon( $coupon_id );
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 {
$gmt_timzone = new \DateTimeZone( 'UTC' ); $gmt_timzone = new \DateTimeZone( 'UTC' );
$date_expires = $coupon->get_date_expires(); $date_expires = $coupon->get_date_expires();
@ -198,6 +208,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
'discount_type' => $coupon->get_discount_type(), 'discount_type' => $coupon->get_discount_type(),
); );
} }
}
$coupon_data[ $idx ]['extended_info'] = $extended_info; $coupon_data[ $idx ]['extended_info'] = $extended_info;
} }
} }
@ -314,6 +325,28 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
return $data; 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. * 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 = $order->get_items( 'coupon' );
$coupon_items_count = count( $coupon_items ); $coupon_items_count = count( $coupon_items );
$num_updated = 0; $num_updated = 0;
$num_deleted = 0;
foreach ( $coupon_items as $coupon_item ) { 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 ] ); unset( $existing_items[ $coupon_id ] );
if ( ! $coupon_id ) { if ( ! $coupon_id ) {
$coupon_items_count--; // Insert a unique, but obviously invalid ID for this deleted coupon.
continue; $num_deleted++;
$coupon_id = -1 * $num_deleted;
} }
$result = $wpdb->replace( $result = $wpdb->replace(
@ -422,27 +457,6 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
ReportsCache::invalidate(); 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. * Gets coupons based on the provided arguments.
* *

View File

@ -237,7 +237,7 @@ class Install {
) $collate; ) $collate;
CREATE TABLE {$wpdb->prefix}wc_order_coupon_lookup ( CREATE TABLE {$wpdb->prefix}wc_order_coupon_lookup (
order_id BIGINT UNSIGNED NOT NULL, 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, date_created datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
discount_amount double DEFAULT 0 NOT NULL, discount_amount double DEFAULT 0 NOT NULL,
PRIMARY KEY (order_id, coupon_id), 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(); WC_Helper_Queue::run_all_pending();
$data_store = new CouponsStatsDataStore(); $data_store = new CouponsStatsDataStore();
$start_time = date( 'Y-m-d 00:00:00', $order->get_date_created()->getOffsetTimestamp() ); $start_time = gmdate( '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() ); $end_time = gmdate( 'Y-m-d 23:59:59', $order->get_date_created()->getOffsetTimestamp() );
$args = array( $args = array(
'after' => $start_time, 'after' => $start_time,
'before' => $end_time, 'before' => $end_time,
@ -104,4 +104,75 @@ class WC_Tests_Reports_Coupons_Stats extends WC_Unit_Test_Case {
$query = new CouponsStatsQuery( $args ); $query = new CouponsStatsQuery( $args );
$this->assertEquals( $expected_data, $query->get_data() ); $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(); WC_Helper_Queue::run_all_pending();
$data_store = new CouponsDataStore(); $data_store = new CouponsDataStore();
$start_time = date( 'Y-m-d 00:00:00', $order->get_date_created()->getOffsetTimestamp() ); $start_time = gmdate( '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() ); $end_time = gmdate( 'Y-m-d 23:59:59', $order->get_date_created()->getOffsetTimestamp() );
$args = array( $args = array(
'after' => $start_time, 'after' => $start_time,
'before' => $end_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['orders_count'],
$coupon_2_response['amount'], $coupon_2_response['amount'],
$coupon_2_response['extended_info']['date_created'], $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'], $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['orders_count'],
$coupon_1_response['amount'], $coupon_1_response['amount'],
$coupon_1_response['extended_info']['date_created'], $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'], $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( 2, $export->get_total_exported() );
$this->assertEquals( $expected_csv, $actual_csv ); $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 );
}
} }