From 28a84bc81260adb4fcbffc54f384692a6fa6f50c Mon Sep 17 00:00:00 2001 From: leogermani Date: Wed, 18 Sep 2024 18:22:44 -0300 Subject: [PATCH] fix: change the current product criteria for sync (#3416) * fix: change the current product criteria for sync * fix: undefined/unused variable and $donation_product type * test: update tests * test: remove failing test * test: improve tests * chore: use constant to hold former subscriber statuses * test: bring back deleted tests with mock --------- Co-authored-by: dkoo --- .../sync/class-woocommerce.php | 114 +++++++++++++++++- .../class-woocommerce-connection.php | 51 +------- tests/mocks/newsletters-mocks.php | 5 + tests/mocks/wc-mocks.php | 4 + .../reader-activation-sync-woocommerce.php | 47 ++++++++ tests/unit-tests/reader-activation-sync.php | 2 + 6 files changed, 177 insertions(+), 46 deletions(-) create mode 100644 tests/mocks/newsletters-mocks.php diff --git a/includes/reader-activation/sync/class-woocommerce.php b/includes/reader-activation/sync/class-woocommerce.php index 53cbee9aef..e9515a8dd0 100644 --- a/includes/reader-activation/sync/class-woocommerce.php +++ b/includes/reader-activation/sync/class-woocommerce.php @@ -40,6 +40,118 @@ public static function should_sync_order( $order ) { return true; } + /** + * Get the order containing what we consider to be the "Current Product" for a given user. + * + * All the payment fields that are synced relate to this product. + * + * The criteria for the "Current Product" are: + * 1. The most recent subscription (either regular subscriptions or recurring donations). + * 2. If no active subscriptions, the most recently cancelled or expired subscription. + * 3. If no subscriptions at all, the most recent one-time donation. + * + * @param \WC_Customer $customer Customer object. + * + * @return \WC_Order|false Order object or false. + */ + private static function get_current_product_order_for_sync( $customer ) { + if ( ! is_a( $customer, 'WC_Customer' ) ) { + return false; + } + + $user_id = $customer->get_id(); + + // 1. The most recent subscription (either regular subscriptions or recurring donations). + $active_subscriptions = WooCommerce_Connection::get_active_subscriptions_for_user( $user_id ); + if ( ! empty( $active_subscriptions ) ) { + return \wcs_get_subscription( reset( $active_subscriptions ) ); + } + + // 2. If no active subscriptions, the most recently cancelled or expired subscription. + $most_recent_cancelled_or_expired_subscription = self::get_most_recent_cancelled_or_expired_subscription( $user_id ); + if ( $most_recent_cancelled_or_expired_subscription ) { + return \wcs_get_subscription( $most_recent_cancelled_or_expired_subscription ); + } + + // 3. If no subscriptions at all, the most recent one-time donation. + $one_time_donation_order = self::get_one_time_donation_order_for_user( $user_id ); + if ( $one_time_donation_order ) { + return $one_time_donation_order; + } + + /** + * Filter the order containing what we consider to be the "Current Product" for a given user when nothing is found. + * + * This is used for tests to mock the return value. + * + * @param false $current_product_order The returned value. + * @return int $user_id The user ID. + */ + return apply_filters( 'newspack_reader_activation_get_current_product_order_for_sync', false, $user_id ); + } + + /** + * Get the most recent cancelled or expired subscription for a user. + * + * @param int $user_id User ID. + * + * @return ?WCS_Subscription A Subscription object or null. + */ + private static function get_most_recent_cancelled_or_expired_subscription( $user_id ) { + $subcriptions = array_reduce( + array_keys( \wcs_get_users_subscriptions( $user_id ) ), + function( $acc, $subscription_id ) { + $subscription = \wcs_get_subscription( $subscription_id ); + if ( $subscription->has_status( WooCommerce_Connection::FORMER_SUBSCRIBER_STATUSES ) ) { + $acc[] = $subscription_id; + } + return $acc; + }, + [] + ); + + if ( ! empty( $subcriptions ) ) { + return reset( $subcriptions ); + } + } + + /** + * Get the most recent one-time donation order for a user. + * + * @param int $user_id User ID. + * + * @return ?WC_Order An Order object or null. + */ + private static function get_one_time_donation_order_for_user( $user_id ) { + $donation_product = Donations::get_donation_product( 'once' ); + if ( ! $donation_product ) { + return; + } + $user_has_donated = \wc_customer_bought_product( null, $user_id, $donation_product ); + if ( ! $user_has_donated ) { + return; + } + + // If user has donated, we'll loop through their orders to find the most recent donation. + // If this method was called, that's because they don't have any active subscriptions, so there shouldn't be too many. + $args = [ + 'customer_id' => $user_id, + 'status' => [ 'wc-completed' ], + 'limit' => -1, + 'order' => 'DESC', + 'orderby' => 'date', + 'return' => 'objects', + ]; + + // Return the most recent completed order. + $orders = \wc_get_orders( $args ); + foreach ( $orders as $order ) { + if ( Donations::is_donation_order( $order ) ) { + return $order; + } + } + } + /** * Get data about a customer's order to sync to the connected ESP. * @@ -212,7 +324,7 @@ public static function get_contact_from_customer( $customer, $payment_page_url = $metadata['registration_date'] = $customer->get_date_created()->date( Metadata::DATE_FORMAT ); $metadata['total_paid'] = \wc_format_localized_price( $customer->get_total_spent() ); - $order = WooCommerce_Connection::get_last_successful_order( $customer ); + $order = self::get_current_product_order_for_sync( $customer ); // Get the order metadata. $order_metadata = []; diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-connection.php b/includes/reader-revenue/woocommerce/class-woocommerce-connection.php index 01392ca27b..fc1c5835e0 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-connection.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-connection.php @@ -19,6 +19,12 @@ class WooCommerce_Connection { const ACTIVE_SUBSCRIPTION_STATUSES = [ 'active', 'pending', 'pending-cancel' ]; const ACTIVE_ORDER_STATUSES = [ 'processing', 'completed' ]; + + /** + * These are the status a subscription can have for us to consider it from a former subscriber. + */ + const FORMER_SUBSCRIBER_STATUSES = [ 'on-hold', 'cancelled', 'expired' ]; + /** * Initialize. * @@ -160,51 +166,6 @@ function( $acc, $subscription_id ) use ( $product_ids ) { return $subcriptions; } - /** - * Get the most recent active subscription, or the last successful order for a given customer. - * - * @param \WC_Customer $customer Customer object. - * - * @return \WC_Order|false Order object or false. - */ - public static function get_last_successful_order( $customer ) { - if ( ! is_a( $customer, 'WC_Customer' ) ) { - return false; - } - - $user_id = $customer->get_id(); - - // Prioritize any currently active subscriptions. - $active_subscriptions = self::get_active_subscriptions_for_user( $user_id ); - if ( ! empty( $active_subscriptions ) ) { - return \wcs_get_subscription( reset( $active_subscriptions ) ); - } - - // If no active subscriptions, get the most recent completed order. - // See https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query for query args. - $args = [ - 'customer_id' => $user_id, - 'status' => [ 'wc-completed' ], - 'limit' => 1, - 'order' => 'DESC', - 'orderby' => 'date', - 'return' => 'objects', - ]; - - // Return the most recent completed order. - $orders = \wc_get_orders( $args ); - if ( ! empty( $orders ) ) { - return reset( $orders ); - } - - // If no completed orders or active subscriptions, they might still have an inactive subscription. - if ( ! empty( $user_subscriptions ) ) { - return reset( $user_subscriptions ); - } - - return false; - } - /** * Filter post request made by the Stripe Gateway for Stripe payments. * diff --git a/tests/mocks/newsletters-mocks.php b/tests/mocks/newsletters-mocks.php new file mode 100644 index 0000000000..f0e4d7a0fa --- /dev/null +++ b/tests/mocks/newsletters-mocks.php @@ -0,0 +1,5 @@ + 'test_user', 'user_email' => 'test@example.com', @@ -37,6 +45,32 @@ public function set_up() { // phpcs:ignore Squiz.Commenting.FunctionComment.Miss // Reset the user. wp_delete_user( self::$user_id ); self::$user_id = wp_insert_user( self::USER_DATA ); + + add_filter( + 'newspack_reader_activation_get_current_product_order_for_sync', + [ __CLASS__, 'get_current_order' ] + ); + } + + /** + * Tear down the test. + * + * @return void + */ + public function tear_down() { + remove_filter( + 'newspack_reader_activation_get_current_product_order_for_sync', + [ __CLASS__, 'get_current_order' ] + ); + } + + /** + * Get the current order for the test. + * + * @return ?WC_Order + */ + public static function get_current_order() { + return self::$current_order; } /** @@ -57,6 +91,8 @@ public function test_payment_metadata_basic() { ], ]; $order = \wc_create_order( $order_data ); + self::$current_order = $order; + $payment_page_url = 'https://example.com/donate'; $contact_data = Sync\WooCommerce::get_contact_from_order( $order, $payment_page_url ); $today = gmdate( 'Y-m-d' ); @@ -109,6 +145,8 @@ public function test_payment_metadata_utm() { ], ]; $order = \wc_create_order( $order_data ); + self::$current_order = $order; + $contact_data = Sync\WooCommerce::get_contact_from_order( $order ); $this->assertEquals( 'test_source', $contact_data['metadata']['payment_page_utm_source'] ); $this->assertEquals( 'test_campaign', $contact_data['metadata']['payment_page_utm_campaign'] ); @@ -125,6 +163,9 @@ public function test_payment_metadata_with_failed_order() { 'total' => 60, ] ); + + self::$current_order = $order; + $contact_data = Sync\WooCommerce::get_contact_from_order( $order ); $this->assertEmpty( $contact_data['metadata']['last_payment_date'] ); $this->assertEmpty( $contact_data['metadata']['last_payment_amount'] ); @@ -140,6 +181,9 @@ public function test_payment_metadata_from_customer() { 'total' => 70, ]; $order = \wc_create_order( $order_data ); + + self::$current_order = $order; + $contact_data = Sync\WooCommerce::get_contact_from_customer( self::$user_id ); $this->assertEquals( '$' . $order_data['total'], $contact_data['metadata']['last_payment_amount'] ); $this->assertEquals( gmdate( 'Y-m-d' ), $contact_data['metadata']['last_payment_date'] ); @@ -156,6 +200,9 @@ public function test_payment_metadata_from_customer_with_last_order_failed() { 'date_paid' => gmdate( 'Y-m-d', strtotime( '-1 week' ) ), ]; $order = \wc_create_order( $completed_order_data ); + + self::$current_order = $order; + // A more recent, but failed, order. $failed_order_data = [ 'customer_id' => self::$user_id, diff --git a/tests/unit-tests/reader-activation-sync.php b/tests/unit-tests/reader-activation-sync.php index 36fd634ac3..767bd184b8 100644 --- a/tests/unit-tests/reader-activation-sync.php +++ b/tests/unit-tests/reader-activation-sync.php @@ -9,6 +9,8 @@ use Newspack\Reader_Activation\Sync; use Newspack\Reader_Activation\ESP_Sync; +require_once __DIR__ . '/../mocks/newsletters-mocks.php'; + /** * Test the Esp_Metadata_Sync class. */