Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions src/API/Google/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,13 @@ protected function generate_shipping_settings(): ShippingSettings {
}

/**
* Returns a `[ country => currency ]` map for every non-manual market.
* Returns a `[ country => currency ]` map for every participating,
* non-manual market.
*
* Each market contributes its country and the first entry of its `currency[]`
* array. Manual markets are skipped — they don't get an MC shipping service.
* Markets excluded from syncing while currency conversion is unavailable
* are skipped for the same reason.
*
* @return array<string, string>
*/
Expand All @@ -160,7 +163,7 @@ protected function build_country_currency_map(): array {
$market_service = $this->container->get( MarketService::class );

$map = [];
foreach ( $market_service->get_markets() as $market ) {
foreach ( $market_service->get_participating_markets() as $market ) {
if ( 'manual' === ( $market['shipping_rate'] ?? null ) ) {
continue;
}
Expand Down Expand Up @@ -265,12 +268,33 @@ protected function get_shipping_times(): array {
/**
* Get shipping rate data.
*
* Rows belonging to secondary markets that are currently excluded from
* syncing (non-store currency while conversion is unavailable) are left
* out, so those countries get no Merchant Center shipping service while
* their markets sit out. All other rows pass through untouched.
*
* @return array
*/
protected function get_shipping_rates_from_database(): array {
$rate_query = $this->container->get( ShippingRateQuery::class );
/** @var MarketService $market_service */
$market_service = $this->container->get( MarketService::class );

$excluded_countries = $market_service->get_excluded_market_countries();
$rates = $rate_query->get_results();

return $rate_query->get_results();
if ( empty( $excluded_countries ) ) {
return $rates;
}

return array_values(
array_filter(
$rates,
function ( array $rate ) use ( $excluded_countries ): bool {
return ! in_array( $rate['country'] ?? null, $excluded_countries, true );
}
)
);
}

/**
Expand Down
22 changes: 21 additions & 1 deletion src/Google/GoogleProductService.php
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,29 @@ protected function validate_batch_request_entry( $request_entry, string $method
protected static function get_batch_response_error_messages( GoogleBatchResponseEntry $batch_response_entry ): array {
$errors = [];
foreach ( $batch_response_entry->getErrors()->getErrors() as $error ) {
$errors[ $error->getReason() ] = $error->getMessage();
$errors[ self::normalise_error_reason( (string) $error->getReason() ) ] = $error->getMessage();
}

return $errors;
}

/**
* Normalises a batch error reason to the camelCase form used by the reason
* constants (e.g. `internal_error` becomes `internalError`).
*
* Google has sent the same failure under both spellings, and a reason that
* does not match the constants is invisible to the retry and
* failure-tracking logic keyed off these values.
*
* @param string $reason The error reason as sent by Google.
*
* @return string
*/
protected static function normalise_error_reason( string $reason ): string {
if ( false === strpos( $reason, '_' ) ) {
return $reason;
}

return lcfirst( str_replace( ' ', '', ucwords( str_replace( '_', ' ', $reason ) ) ) );
}
}
13 changes: 13 additions & 0 deletions src/Integration/WPML.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ public function is_active(): bool {
*/
public function init(): void {}

/**
* Returns whether product prices can be converted into another currency.
*
* This is the exact availability condition used by the price conversion
* methods below: WPML active with WCML multi-currency enabled. Markets
* priced in a non-store currency can only be synced when this holds.
*
* @return bool
*/
public function can_convert_currency(): bool {
return $this->is_active() && $this->is_wcml_multi_currency_on();
}

/**
* Returns the site's default WPML language code.
*
Expand Down
186 changes: 170 additions & 16 deletions src/MerchantCenter/MarketService.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,68 @@ public function __construct(
/**
* Register the service.
*
* No WordPress hooks are needed for this pure data service.
* Watches for changes in currency conversion availability so market
* participation changes propagate to Merchant Center without waiting for
* an unrelated sync.
*/
public function register(): void {}
public function register(): void {
add_action(
'init',
function () {
$this->handle_conversion_availability_change();
}
);
}

/**
* Schedules a full product re-sync and a shipping settings sync when
* currency conversion availability changes (WPML activated or deactivated,
* or WCML multi-currency toggled).
*
* Markets priced in a non-store currency take part in syncing only while
* conversion is available, so an availability change re-labels what should
* exist in Merchant Center: the scheduled re-sync's stale-entry cleanup
* removes entries for markets that dropped out, and re-syncs markets that
* rejoined. Stores without such markets skip the scheduling entirely.
*/
private function handle_conversion_availability_change(): void {
$available = $this->wpml->can_convert_currency() ? 'yes' : 'no';
$stored = $this->options->get( OptionsInterface::CURRENCY_CONVERSION_AVAILABLE );

if ( $available === $stored ) {
return;
}

$this->options->update( OptionsInterface::CURRENCY_CONVERSION_AVAILABLE, $available );

// First run only records the state; there is no change to act on.
if ( null === $stored ) {
return;
}

if ( ! $this->has_markets_requiring_conversion() ) {
return;
}

$this->schedule_shipping_sync();
$this->job_repository->get( UpdateAllProducts::class )->schedule();
}

/**
* Whether any stored secondary market is priced in a currency other than
* the store currency, i.e. depends on conversion availability to take part.
*
* @return bool
*/
private function has_markets_requiring_conversion(): bool {
foreach ( $this->get_stored_secondary_markets() as $market ) {
if ( get_woocommerce_currency() !== $this->get_market_currency( $market ) ) {
return true;
}
}

return false;
}

/**
* Returns all markets, keyed by ID with the synthesised primary always first.
Expand Down Expand Up @@ -145,6 +204,94 @@ public function get_markets(): array {
return [ 'primary' => $this->get_primary_market() ] + $secondary;
}

/**
* Returns the markets that currently take part in syncing to Google.
*
* Same as get_markets() minus secondary markets priced in a non-store
* currency while currency conversion is unavailable — their prices cannot
* be converted, and submitting store-currency prices against a
* market-currency service and feed label is rejected by Google. Stored
* market data is never modified; an excluded market reappears here the
* moment conversion is available again.
*
* Use get_markets() for anything user-facing (the Markets page must keep
* showing every saved market); use this for anything that feeds Google.
*
* @return array[] Keyed by market ID ('primary', then secondary IDs).
*/
public function get_participating_markets(): array {
$markets = $this->get_markets();

foreach ( $markets as $id => $market ) {
if ( 'primary' === $id ) {
continue;
}

if ( ! $this->is_market_participating( $market ) ) {
unset( $markets[ $id ] );
}
}

return $markets;
}

/**
* Returns the countries of stored secondary markets that are currently
* excluded from syncing (see get_participating_markets()). Used to keep
* those countries' shipping services out of the Merchant Center shipping
* settings while their markets sit out.
*
* @return string[]
*/
public function get_excluded_market_countries(): array {
$countries = [];

foreach ( $this->get_stored_secondary_markets() as $market ) {
if ( $this->is_market_participating( $market ) ) {
continue;
}

if ( ! empty( $market['country'] ) ) {
$countries[] = $market['country'];
}
}

return array_values( array_unique( $countries ) );
}

/**
* Whether a secondary market currently takes part in syncing to Google.
*
* A market priced in the store currency always takes part. A market priced
* in any other currency takes part only while price conversion is
* available (WPML active with WCML multi-currency on).
*
* @param array $market The market config.
*
* @return bool
*/
private function is_market_participating( array $market ): bool {
if ( get_woocommerce_currency() === $this->get_market_currency( $market ) ) {
return true;
}

return $this->wpml->can_convert_currency();
}

/**
* Returns the stored secondary markets that currently take part in syncing.
*
* @return array[]
*/
private function get_participating_secondary_markets(): array {
return array_filter(
$this->get_stored_secondary_markets(),
function ( array $market ): bool {
return $this->is_market_participating( $market );
}
);
}

/**
* Persists a new markets configuration.
*
Expand Down Expand Up @@ -699,8 +846,10 @@ public function get_feed_labels_for_language( string $language ): array {
* Returns each market's derived feed label together with its configured languages.
*
* The primary market contributes its bare main feed label with the primary
* language list; each secondary market contributes its currency-derived
* label with its stored language list.
* language list; each participating secondary market contributes its
* currency-derived label with its stored language list. Excluded markets
* (see get_participating_markets()) contribute nothing, which is what lets
* the stale-entry cleanup remove their Merchant Center entries.
*
* @return array<int, array{feed_label: string, languages: array}>
*/
Expand All @@ -712,7 +861,7 @@ private function get_market_labels_with_languages(): array {
],
];

foreach ( $this->get_stored_secondary_markets() as $market ) {
foreach ( $this->get_participating_secondary_markets() as $market ) {
$base_feed_label = (string) ( $market['feed_label'] ?? '' );

$markets[] = [
Expand Down Expand Up @@ -771,15 +920,18 @@ public function get_main_feed_label(): string {
}

/**
* Returns every country code across all markets without duplicates.
* Returns every country code across all participating markets without duplicates.
*
* The primary market contributes its target_audience countries; each
* secondary market contributes its single `country` value. No DB queries.
* participating secondary market contributes its single `country` value.
* Excluded markets (see get_participating_markets()) contribute nothing
* so products stop advertising shipping to countries that are not being
* synced. No DB queries.
*
* @return string[]
*/
public function get_all_countries(): array {
$secondary = $this->get_stored_secondary_markets();
$secondary = $this->get_participating_secondary_markets();
$countries = $this->target_audience->get_target_countries();

if ( ! empty( $secondary ) ) {
Expand All @@ -793,19 +945,20 @@ public function get_all_countries(): array {

/**
* Returns every country that should receive a Merchant Center shipping
* service: the primary market's target countries plus each non-manual
* secondary market's country.
* service: the primary market's target countries plus each participating,
* non-manual secondary market's country.
*
* Manual markets are excluded because their shipping is managed outside
* the plugin, mirroring the per-market currency map in the Google
* Settings service.
* Settings service. Markets excluded from syncing (see
* get_participating_markets()) get no shipping service either.
*
* @return string[]
*/
public function get_shipping_sync_countries(): array {
$countries = $this->target_audience->get_target_countries();

foreach ( $this->get_stored_secondary_markets() as $market ) {
foreach ( $this->get_participating_secondary_markets() as $market ) {
if ( 'manual' === ( $market['shipping_rate'] ?? null ) ) {
continue;
}
Expand All @@ -821,14 +974,15 @@ public function get_shipping_sync_countries(): array {
/**
* Whether any configured market needs its shipping settings synced to Merchant Center.
*
* Returns true when at least one market has a non-`manual` `shipping_rate`
* combined with `shipping_time === 'flat'`. A non-`manual` secondary market
* is enough to require a sync even when the primary itself is `manual`.
* Returns true when at least one participating market has a non-`manual`
* `shipping_rate` combined with `shipping_time === 'flat'`. A non-`manual`
* secondary market is enough to require a sync even when the primary
* itself is `manual`.
*
* @return bool
*/
public function has_syncable_markets(): bool {
foreach ( $this->get_markets() as $market ) {
foreach ( $this->get_participating_markets() as $market ) {
$rate = $market['shipping_rate'] ?? null;
$time = $market['shipping_time'] ?? null;
if ( 'manual' !== $rate && 'flat' === $time ) {
Expand Down
2 changes: 2 additions & 0 deletions src/Options/OptionsInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface OptionsInterface {
public const CAMPAIGN_CONVERT_STATUS = 'campaign_convert_status';
public const CLAIMED_URL_HASH = 'claimed_url_hash';
public const CONTACT_INFO_SETUP = 'contact_info_setup';
public const CURRENCY_CONVERSION_AVAILABLE = 'currency_conversion_available';
public const DELAYED_ACTIVATE = 'delayed_activate';
public const DB_VERSION = 'db_version';
public const FILE_VERSION = 'file_version';
Expand Down Expand Up @@ -73,6 +74,7 @@ interface OptionsInterface {
self::CAMPAIGN_CONVERT_STATUS => true,
self::CLAIMED_URL_HASH => true,
self::CONTACT_INFO_SETUP => true,
self::CURRENCY_CONVERSION_AVAILABLE => true,
self::DB_VERSION => true,
self::FILE_VERSION => true,
self::GOOGLE_CONNECTED => true,
Expand Down
6 changes: 5 additions & 1 deletion src/Product/BatchProductHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,11 @@ public function validate_and_generate_update_request_entries( array $products ):
);
}

foreach ( $this->market_service->get_markets() as $market_id => $market ) {
// Participating markets only: a market priced in a non-store
// currency sits out while conversion is unavailable, because
// submitting unconverted prices against its currency-derived
// label and shipping service is rejected by Google.
foreach ( $this->market_service->get_participating_markets() as $market_id => $market ) {
if ( 'primary' === $market_id ) {
continue;
}
Expand Down
Loading
Loading