diff --git a/includes/class-donations.php b/includes/class-donations.php index ed758d3bdd..8e3a29c245 100644 --- a/includes/class-donations.php +++ b/includes/class-donations.php @@ -278,6 +278,12 @@ public static function get_donation_product_child_products_ids() { * @return boolean True if a donation product, false if not. */ public static function is_donation_product( $product_id ) { + // Check the meta flag first (fast path). + if ( function_exists( 'wc_bool_to_string' ) && get_post_meta( $product_id, WooCommerce_Products::DONATION_FLAG_META_KEY, true ) === wc_bool_to_string( true ) ) { + return true; + } + + // Fall back to the legacy parent/child donation product check. $parent_product = self::get_parent_donation_product(); if ( ! $parent_product ) { return false; @@ -286,6 +292,37 @@ public static function is_donation_product( $product_id ) { return in_array( $product_id, $donation_product_ids, true ) || $product_id === $parent_product->get_id(); } + /** + * Get IDs of all products flagged as donations via the _newspack_is_donation meta. + * + * @return int[] Array of product IDs. + */ + public static function get_flagged_donation_product_ids() { + static $memo = null; + if ( null !== $memo ) { + return $memo; + } + if ( ! function_exists( 'wc_bool_to_string' ) ) { + return []; + } + $flagged_products = get_posts( + [ + 'post_type' => 'product', + 'post_status' => 'any', + 'posts_per_page' => -1, + 'fields' => 'ids', + 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + [ + 'key' => WooCommerce_Products::DONATION_FLAG_META_KEY, + 'value' => wc_bool_to_string( true ), + ], + ], + ] + ); + $memo = array_map( 'intval', $flagged_products ); + return $memo; + } + /** * Whether the order is a donation. * @@ -309,7 +346,7 @@ public static function is_donation_order( $order ) { * @return int|false The donation product ID or false. */ public static function get_order_donation_product_id( $order_id ) { - $donation_products = self::get_donation_product_child_products_ids(); + $donation_products = array_merge( self::get_donation_product_child_products_ids(), self::get_flagged_donation_product_ids() ); if ( empty( array_filter( $donation_products ) ) ) { return; } @@ -1051,15 +1088,11 @@ public static function is_donation_cart() { if ( ! self::is_platform_wc() ) { return false; } - $donation_products_ids = array_values( self::get_donation_product_child_products_ids() ); - if ( empty( $donation_products_ids ) ) { - return false; - } if ( ! WC()->cart || ! WC()->cart->cart_contents || ! is_array( WC()->cart->cart_contents ) ) { return false; } foreach ( WC()->cart->cart_contents as $prod_in_cart ) { - if ( isset( $prod_in_cart['product_id'] ) && in_array( $prod_in_cart['product_id'], $donation_products_ids ) ) { + if ( isset( $prod_in_cart['product_id'] ) && self::is_donation_product( $prod_in_cart['product_id'] ) ) { return true; } } diff --git a/includes/contribution-meter/class-contribution-meter.php b/includes/contribution-meter/class-contribution-meter.php index 075b80ef10..61c6f8213e 100644 --- a/includes/contribution-meter/class-contribution-meter.php +++ b/includes/contribution-meter/class-contribution-meter.php @@ -260,9 +260,11 @@ public static function get_donation_revenue( $start_date, $end_date ) { return new \WP_Error( 'woocommerce_inactive', __( 'WooCommerce is not active.', 'newspack-plugin' ) ); } - // Get all donation product IDs. + // Get all donation product IDs (default + flagged). $donation_products = Donations::get_donation_product_child_products_ids(); $donation_product_ids = array_filter( array_map( 'intval', array_values( $donation_products ) ) ); + $flagged_product_ids = Donations::get_flagged_donation_product_ids(); + $donation_product_ids = array_unique( array_merge( $donation_product_ids, $flagged_product_ids ) ); if ( empty( $donation_product_ids ) ) { return new \WP_Error( 'no_donation_products', __( 'No donation products found.', 'newspack-plugin' ) ); diff --git a/includes/plugins/woocommerce/class-woocommerce-products.php b/includes/plugins/woocommerce/class-woocommerce-products.php index 2c18e4b248..08bdcad042 100644 --- a/includes/plugins/woocommerce/class-woocommerce-products.php +++ b/includes/plugins/woocommerce/class-woocommerce-products.php @@ -15,6 +15,8 @@ * Connection with WooCommerce's features. */ class WooCommerce_Products { + + const DONATION_FLAG_META_KEY = '_newspack_is_donation'; /** * Initialize. * @@ -70,6 +72,15 @@ public static function get_custom_options() { 'product_types' => [ 'simple', 'variation', 'subscription', 'subscription_variation' ], 'type' => 'boolean', ], + 'newspack_is_donation' => [ + 'id' => self::DONATION_FLAG_META_KEY, + 'wrapper_class' => '', + 'label' => __( 'Donation product', 'newspack-plugin' ), + 'description' => __( 'Flag this product as a donation. Donation products use donation-specific checkout, reporting, and reader activation behaviors.', 'newspack-plugin' ), + 'default' => 'no', + 'product_types' => [ 'simple', 'subscription', 'grouped', 'variable', 'variation', 'subscription_variation', 'variable-subscription' ], + 'type' => 'boolean', + ], ]; /** diff --git a/tests/unit-tests/donations.php b/tests/unit-tests/donations.php index ade2ce1648..38bdd32d0b 100644 --- a/tests/unit-tests/donations.php +++ b/tests/unit-tests/donations.php @@ -6,6 +6,7 @@ */ use Newspack\Donations; +use Newspack\WooCommerce_Products; require_once __DIR__ . '/../mocks/wc-mocks.php'; @@ -28,4 +29,64 @@ public function test_donations_settings_wc() { 'WC is the default donations platform.' ); } + + /** + * Test that is_donation_product returns false for unflagged products. + * + * @group donations + */ + public function test_is_donation_product_unflagged() { + $product_id = self::factory()->post->create( [ 'post_type' => 'product' ] ); + self::assertFalse( + Donations::is_donation_product( $product_id ), + 'Unflagged product should not be a donation product.' + ); + } + + /** + * Test that is_donation_product returns true for products with _newspack_is_donation meta. + * + * @group donations + */ + public function test_is_donation_product_flagged() { + $product_id = self::factory()->post->create( [ 'post_type' => 'product' ] ); + update_post_meta( $product_id, WooCommerce_Products::DONATION_FLAG_META_KEY, wc_bool_to_string( true ) ); + self::assertTrue( + Donations::is_donation_product( $product_id ), + 'Flagged product should be a donation product.' + ); + } + + /** + * Test that is_donation_product returns false when meta is removed. + * + * @group donations + */ + public function test_is_donation_product_unflagged_after_removal() { + $product_id = self::factory()->post->create( [ 'post_type' => 'product' ] ); + update_post_meta( $product_id, WooCommerce_Products::DONATION_FLAG_META_KEY, wc_bool_to_string( true ) ); + delete_post_meta( $product_id, WooCommerce_Products::DONATION_FLAG_META_KEY ); + self::assertFalse( + Donations::is_donation_product( $product_id ), + 'Product should not be a donation product after meta removal.' + ); + } + + /** + * Test get_flagged_donation_product_ids returns flagged product IDs. + * + * @group donations + */ + public function test_get_flagged_donation_product_ids() { + $product_1 = self::factory()->post->create( [ 'post_type' => 'product' ] ); + $product_2 = self::factory()->post->create( [ 'post_type' => 'product' ] ); + $product_3 = self::factory()->post->create( [ 'post_type' => 'product' ] ); + update_post_meta( $product_1, WooCommerce_Products::DONATION_FLAG_META_KEY, wc_bool_to_string( true ) ); + update_post_meta( $product_3, WooCommerce_Products::DONATION_FLAG_META_KEY, wc_bool_to_string( true ) ); + + $flagged_ids = Donations::get_flagged_donation_product_ids(); + self::assertContains( $product_1, $flagged_ids, 'Flagged product 1 should be in the list.' ); + self::assertNotContains( $product_2, $flagged_ids, 'Unflagged product 2 should not be in the list.' ); + self::assertContains( $product_3, $flagged_ids, 'Flagged product 3 should be in the list.' ); + } }