Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure coupon brand restrictions are uploaded to Google Merchant Center. #2697

Merged
merged 10 commits into from
Dec 5, 2024
88 changes: 87 additions & 1 deletion src/Coupon/SyncerHooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\NotificationStatus;
use WC_Coupon;
defined( 'ABSPATH' ) || exit();
Expand Down Expand Up @@ -89,6 +90,13 @@
*/
protected $wc;

/**
* WP Proxy
*
* @var WP
*/
protected WP $wp;

/**
* SyncerHooks constructor.
*
Expand All @@ -97,13 +105,15 @@
* @param MerchantCenterService $merchant_center
* @param NotificationsService $notifications_service
* @param WC $wc
* @param WP $wp
*/
public function __construct(
CouponHelper $coupon_helper,
JobRepository $job_repository,
MerchantCenterService $merchant_center,
NotificationsService $notifications_service,
WC $wc
WC $wc,
WP $wp
) {
$this->update_coupon_job = $job_repository->get( UpdateCoupon::class );
$this->delete_coupon_job = $job_repository->get( DeleteCoupon::class );
Expand All @@ -112,6 +122,7 @@
$this->merchant_center = $merchant_center;
$this->notifications_service = $notifications_service;
$this->wc = $wc;
$this->wp = $wp;
}

/**
Expand All @@ -138,6 +149,9 @@

// when a coupon is restored from trash, schedule a update job.
add_action( 'untrashed_post', [ $this, 'update_by_id' ], 90 );

// Update coupons when object terms get updated.
add_action( 'set_object_terms', [ $this, 'maybe_update_by_id_when_terms_updated' ], 90, 6 );
}

/**
Expand All @@ -152,6 +166,20 @@
}
}

/**
* Update a coupon by the ID when the terms get updated.
*
* @param int $object_id The object ID.
* @param array $terms An array of object term IDs or slugs.
* @param array $tt_ids An array of term taxonomy IDs.
* @param string $taxonomy The taxonomy slug.
* @param bool $append Whether to append new terms to the old terms.
* @param array $old_tt_ids Old array of term taxonomy IDs.
*/
public function maybe_update_by_id_when_terms_updated( int $object_id, array $terms, array $tt_ids, string $taxonomy, bool $append, array $old_tt_ids ) {
$this->handle_update_coupon_when_product_brands_updated( $taxonomy, $tt_ids, $old_tt_ids );
}

/**
* Delete a coupon by the ID
*
Expand Down Expand Up @@ -411,4 +439,62 @@
);
}
}

/**
* If product to brands relationship is updated, update the coupons that are related to the brands.
*
* @param string $taxonomy The taxonomy slug.
* @param array $tt_ids An array of term taxonomy IDs.
* @param array $old_tt_ids Old array of term taxonomy IDs.
*/
protected function handle_update_coupon_when_product_brands_updated( string $taxonomy, array $tt_ids, array $old_tt_ids ) {
if ( 'product_brand' !== $taxonomy ) {
return;
}

// Convert term taxonomy IDs to integers.
$tt_ids = array_map( 'intval', $tt_ids );
$old_tt_ids = array_map( 'intval', $old_tt_ids );

// Find the difference between the new and old term taxonomy IDs.
$diff1 = array_diff( $tt_ids, $old_tt_ids );
$diff2 = array_diff( $old_tt_ids, $tt_ids );
$diff = array_merge( $diff1, $diff2 );

if ( empty( $diff ) ) {
return;

Check warning on line 465 in src/Coupon/SyncerHooks.php

View check run for this annotation

Codecov / codecov/patch

src/Coupon/SyncerHooks.php#L465

Added line #L465 was not covered by tests
}

// Serialize the diff to use in the meta query.
// This is needed because the meta value is serialized.
$serialized_diff = maybe_serialize( $diff );

$args = [
'post_type' => 'shop_coupon',
'meta_query' => [
'relation' => 'OR',
[
'key' => 'product_brands',
'value' => $serialized_diff,
'compare' => 'LIKE',
],
[
'key' => 'exclude_product_brands',
'value' => $serialized_diff,
'compare' => 'LIKE',
],
],
];

// Get coupon posts based on the above query args.
$posts = $this->wp->get_posts( $args );

if ( empty( $posts ) ) {
return;

Check warning on line 493 in src/Coupon/SyncerHooks.php

View check run for this annotation

Codecov / codecov/patch

src/Coupon/SyncerHooks.php#L493

Added line #L493 was not covered by tests
}

foreach ( $posts as $post ) {
$this->update_by_id( $post->ID );
}
}
}
53 changes: 50 additions & 3 deletions src/Coupon/WCCouponAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,25 @@
$this->setItemId( $google_product_ids );
}

$wc_excluded_product_ids = $wc_coupon->get_excluded_product_ids();
if ( ! empty( $wc_excluded_product_ids ) ) {
$google_product_ids = array_map( $get_offer_id, $wc_excluded_product_ids );
// Currently the brand inclusion restriction will override the product inclustion restriction.
// It's align with the current coupon discounts behaviour in WooCommerce.
$wc_product_ids_in_brand = $this->get_product_ids_in_brand( $wc_coupon );
if ( ! empty( $wc_product_ids_in_brand ) ) {
$google_product_ids = array_map( $get_offer_id, $wc_product_ids_in_brand );
$has_product_restriction = true;
$this->setItemId( $google_product_ids );
}

// Get excluded product IDs and excluded product IDs in brand.
$wc_excluded_product_ids = $wc_coupon->get_excluded_product_ids();
$wc_excluded_product_ids_in_brand = $this->get_product_ids_in_brand( $wc_coupon, true );
if ( ! empty( $wc_excluded_product_ids ) || ! empty( $wc_excluded_product_ids_in_brand ) ) {
$google_product_ids = array_merge(
array_map( $get_offer_id, $wc_excluded_product_ids ),
array_map( $get_offer_id, $wc_excluded_product_ids_in_brand )
);
$google_product_ids = array_values( array_unique( $google_product_ids ) );

$has_product_restriction = true;
$this->setItemIdExclusion( $google_product_ids );
}
Expand Down Expand Up @@ -390,4 +406,35 @@

return apply_filters( 'woocommerce_gla_coupon_destinations', $destinations, $coupon_data );
}

/**
* Get the product IDs that belongs to a brand.
*
* @param WC_Coupon $wc_coupon The WC coupon object.
* @param bool $is_exclude If the product IDs are for exclusion.
* @return string[] The product IDs that belongs to a brand.
*/
private function get_product_ids_in_brand( WC_Coupon $wc_coupon, bool $is_exclude = false ) {
$coupon_id = $wc_coupon->get_id();
$meta_key = $is_exclude ? 'exclude_product_brands' : 'product_brands';

// Get the brand term IDs if brand restriction is set.
$brand_term_ids = get_post_meta( $coupon_id, $meta_key );

if ( ! is_array( $brand_term_ids ) ) {
return [];

Check warning on line 425 in src/Coupon/WCCouponAdapter.php

View check run for this annotation

Codecov / codecov/patch

src/Coupon/WCCouponAdapter.php#L425

Added line #L425 was not covered by tests
}

$product_ids = [];
foreach ( $brand_term_ids as $brand_term_id ) {
// Get the product IDs that belongs to the brand.
$object_ids = get_objects_in_term( $brand_term_id, 'product_brand' );
if ( is_wp_error( $object_ids ) ) {
continue;

Check warning on line 433 in src/Coupon/WCCouponAdapter.php

View check run for this annotation

Codecov / codecov/patch

src/Coupon/WCCouponAdapter.php#L433

Added line #L433 was not covered by tests
}
$product_ids = array_merge( $product_ids, $object_ids );
}

return $product_ids;
}
}
4 changes: 3 additions & 1 deletion src/Internal/DependencyManagement/JobServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\Product;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping;
use Automattic\WooCommerce\GoogleListingsAndAds\Settings;

Expand Down Expand Up @@ -161,7 +162,8 @@
JobRepository::class,
MerchantCenterService::class,
NotificationsService::class,
WC::class
WC::class,
WP::class

Check warning on line 166 in src/Internal/DependencyManagement/JobServiceProvider.php

View check run for this annotation

Codecov / codecov/patch

src/Internal/DependencyManagement/JobServiceProvider.php#L165-L166

Added lines #L165 - L166 were not covered by tests
);

$this->share_with_tags( StartProductSync::class, JobRepository::class );
Expand Down
56 changes: 55 additions & 1 deletion tests/Unit/Coupon/SyncerHooksTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Framework\ContainerAwareUnitTest;
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Tools\HelperTrait\CouponTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\NotificationStatus;
use PHPUnit\Framework\MockObject\MockObject;
use WC_Coupon;
use WC_Helper_Coupon;
use WC_Helper_Product;

/**
* Class SyncerHooksTest
Expand Down Expand Up @@ -55,6 +57,9 @@ class SyncerHooksTest extends ContainerAwareUnitTest {
/** @var WC $wc */
protected $wc;

/** @var WP $wp */
protected $wp;

/** @var SyncerHooks $syncer_hooks */
protected $syncer_hooks;

Expand Down Expand Up @@ -265,6 +270,51 @@ public function test_delete_coupon_triggers_notification_delete() {
$coupon->save();
}

public function test_product_brands_updated_schedules_update_job() {
// compatibility-code "WC < 9.4" -- Brands in core was added in WooCommerce 9.4
if ( version_compare( WC_VERSION, '9.4', '<' ) ) {
self::markTestSkipped( 'WooCommerce 9.4 or newer is needed to test WooCommerce Brands in core.' );
}

require_once WC_ABSPATH . '/includes/class-wc-brands.php';
\WC_Brands::init_taxonomy();

// Create products and brands.
/**
* @var WC_Product $product_1
*/
$product_1 = WC_Helper_Product::create_simple_product();
$product_2 = WC_Helper_Product::create_simple_product();
$brand_1 = wp_insert_term( 'Brand 1', 'product_brand' );

$brand_taxonomy = 'product_brand';

// Set the brand 1 for the product 1 and 2.
wp_set_post_terms( $product_1->get_id(), $brand_1['term_id'], $brand_taxonomy );
wp_set_post_terms( $product_2->get_id(), $brand_1['term_id'], $brand_taxonomy );

// Create a coupon.
/**
* @var WC_Coupon $coupon
*/
$coupon = WC_Helper_Coupon::create_coupon( uniqid() );
$coupon->set_status( 'publish' );
$coupon->add_meta_data( '_wc_gla_visibility', ChannelVisibility::SYNC_AND_SHOW, true );
// Add brand 1 to coupon inclusion restriction.
$coupon->add_meta_data( 'product_brands', [ (int) $brand_1['term_id'] ], true );
$coupon->save();

$this->set_mc_and_notifications();
$this->coupon_notification_job->expects( $this->never() )
->method( 'schedule' );
$this->update_coupon_job->expects( $this->once() )
->method( 'schedule' )
->with( $this->equalTo( [ [ $coupon->get_id() ] ] ) );

// Remove brand 1 from the product 1.
wp_set_object_terms( $product_1->get_id(), [], $brand_taxonomy );
}

public function test_actions_not_defined_when_mc_not_ready() {
$this->set_mc_and_notifications( false );

Expand All @@ -277,6 +327,7 @@ public function test_actions_not_defined_when_mc_not_ready() {
$this->assertFalse( has_action( 'deleted_post', [ $this->syncer_hooks, 'delete_by_id' ] ) );
$this->assertFalse( has_action( 'woocommerce_delete_coupon', [ $this->syncer_hooks, 'delete_by_id' ] ) );
$this->assertFalse( has_action( 'woocommerce_trash_coupon', [ $this->syncer_hooks, 'delete_by_id' ] ) );
$this->assertfalse( has_action( 'set_object_terms', [ $this->syncer_hooks, 'maybe_update_by_id_when_terms_updated' ] ) );
}

public function test_actions_defined_when_mc_ready() {
Expand All @@ -291,6 +342,7 @@ public function test_actions_defined_when_mc_ready() {
$this->assertEquals( 90, has_action( 'deleted_post', [ $this->syncer_hooks, 'delete_by_id' ] ) );
$this->assertEquals( 90, has_action( 'woocommerce_delete_coupon', [ $this->syncer_hooks, 'delete_by_id' ] ) );
$this->assertEquals( 90, has_action( 'woocommerce_trash_coupon', [ $this->syncer_hooks, 'delete_by_id' ] ) );
$this->assertEquals( 90, has_action( 'set_object_terms', [ $this->syncer_hooks, 'maybe_update_by_id_when_terms_updated' ] ) );
}

/**
Expand All @@ -313,7 +365,8 @@ public function set_mc_and_notifications( bool $mc_status = true, bool $notifica
$this->job_repository,
$this->merchant_center,
$this->notification_service,
$this->wc
$this->wc,
$this->wp
);

$this->syncer_hooks->register();
Expand Down Expand Up @@ -349,6 +402,7 @@ public function setUp(): void {
);

$this->wc = $this->container->get( WC::class );
$this->wp = $this->container->get( WP::class );
$this->coupon_helper = $this->container->get( CouponHelper::class );
}
}
Loading
Loading