$code ), ENT_QUOTES, 'UTF-8' ),
'languages' => [ $this->get_default_site_language_code() ],
],
];
}
/**
* Schedules the shipping-settings sync job so MC shipping services are
* regenerated for every non-manual market.
*/
private function schedule_shipping_sync(): void {
$this->job_repository->get( UpdateShippingSettings::class )->schedule();
}
/**
* Reacts to the global shipping method changing on the Settings page.
*
* Every market's Merchant Center shipping service is generated from that single global method,
* so a change has to be pushed to Google. The Settings-page save is the one shipping-method
* mutation that happens outside this service, and it never scheduled a resync; this schedules
* the same shipping-settings sync the in-service mutations (add/delete market) already do,
* gated the same way.
*
* Call it after the global method is persisted, only when it actually changed.
*/
public function handle_global_shipping_method_change(): void {
if ( $this->global_shipping_is_syncable() ) {
$this->schedule_shipping_sync();
}
}
/**
* Returns the stored secondary markets from the Markets option.
*
* Returns the stored values verbatim: write paths (add, update, delete)
* merge against this data, so stored locale values must survive partial
* updates even while no multilingual integration is active. Consumption
* paths mask locale values via apply_site_locale_when_not_multilingual().
*
* @return array[]
*/
private function get_stored_secondary_markets(): array {
$stored = $this->options->get( OptionsInterface::MARKETS, [] );
$markets = is_array( $stored ) ? $stored : [];
unset( $markets['primary'] );
return $markets;
}
/**
* Overrides a market config's locale values with the site defaults when no
* multilingual integration is active, and returns it unchanged otherwise.
*
* Without an integration the store cannot produce translated content or
* converted prices, so locale values saved while an integration was still
* active (for example a EUR currency on a USD store) must not drive feed
* labels, submitted prices, or shipping currencies. The stored option is
* left untouched so the values come back when the integration returns.
*
* @param array $market The market config.
*
* @return array The config with `language` and `currency` replaced by the
* site primary language and the store currency when the site
* is not multilingual.
*/
private function apply_site_locale_when_not_multilingual( array $market ): array {
if ( $this->has_multilingual_support() ) {
return $market;
}
$market['language'] = [ $this->get_site_primary_language() ];
// A configured exchange rate is its own conversion source, so the
// market's stored currency stays meaningful without a multilingual
// integration and must survive the masking.
if ( empty( $market['exchange_rate'] ) ) {
$market['currency'] = [ $this->get_site_primary_currency() ];
}
return $market;
}
/**
* Fans out a primary market update to the underlying settings stores.
*
* @param array $config Partial config — only supplied keys are written.
*
* @throws InvalidValue When `language` or `currency` is present but not an array.
*/
private function update_primary_market_fanout( array $config ): void {
// A country owned by a stored secondary market cannot rejoin the primary feed, or it would
// be targeted twice. Checked before anything is written, so a rejection cannot half apply
// the update.
if ( array_key_exists( 'countries', $config ) ) {
foreach ( (array) $config['countries'] as $country ) {
$this->assert_country_unowned( (string) $country );
}
}
$mc_settings = $this->options->get( OptionsInterface::MERCHANT_CENTER, [] );
$mc_updated = false;
if ( array_key_exists( 'shipping_rate', $config ) ) {
$mc_settings['shipping_rate'] = $config['shipping_rate'];
$mc_updated = true;
}
if ( array_key_exists( 'shipping_time', $config ) ) {
$mc_settings['shipping_time'] = $config['shipping_time'];
$mc_updated = true;
}
foreach ( [ 'language', 'currency' ] as $key ) {
if ( ! array_key_exists( $key, $config ) ) {
continue;
}
if ( ! is_array( $config[ $key ] ) ) {
throw InvalidValue::not_array( $key );
}
$mc_settings[ $key ] = array_values( array_unique( $config[ $key ] ) );
$mc_updated = true;
}
if ( $mc_updated ) {
$this->options->update( OptionsInterface::MERCHANT_CENTER, $mc_settings );
}
if ( array_key_exists( 'countries', $config ) ) {
$countries = $config['countries'];
$target_audience = $this->options->get( OptionsInterface::TARGET_AUDIENCE, [] );
// TargetAudience ignores the stored list while the location is "all", so a submitted
// selection only takes effect once the audience is an explicit one.
if ( 'all' === strtolower( (string) ( $target_audience['location'] ?? '' ) ) ) {
$target_audience['location'] = 'selected';
}
$target_audience['countries'] = $countries;
$this->options->update( OptionsInterface::TARGET_AUDIENCE, $target_audience );
}
}
/**
* Returns the free-shipping threshold for the primary market's country.
*
* Uses get_main_target_country() as a single-country fallback for the aggregated
* free_shipping field; multi-country aggregation is out of scope.
*
* @return float|null The threshold amount, or null when unset.
*/
private function get_primary_free_shipping_threshold(): ?float {
if ( ! $this->is_flat_shipping_rate() ) {
return null;
}
$country = $this->target_audience->get_main_target_country();
$rates = $this->get_cached_shipping_rates();
if ( isset( $rates[ $country ]['free_shipping_threshold'] ) ) {
return (float) $rates[ $country ]['free_shipping_threshold'];
}
return null;
}
/**
* Whether the global shipping rate mode is 'flat'.
*
* DB-stored rates only flow to MC in flat mode; automatic mode is driven by
* WC shipping zones, and manual mode is handled outside the plugin.
*
* @return bool
*/
private function is_flat_shipping_rate(): bool {
$mc_settings = $this->options->get( OptionsInterface::MERCHANT_CENTER, [] );
return is_array( $mc_settings ) && 'flat' === ( $mc_settings['shipping_rate'] ?? null );
}
/**
* Returns the store-wide shipping method from the MERCHANT_CENTER option.
*
* This is the single source of truth for every market's shipping_rate and
* shipping_time; both values are null when the setting is unset.
*
* @return array{shipping_rate: string|null, shipping_time: string|null}
*/
private function global_shipping_method(): array {
$mc_settings = $this->options->get( OptionsInterface::MERCHANT_CENTER, [] );
if ( ! is_array( $mc_settings ) ) {
$mc_settings = [];
}
return [
'shipping_rate' => $mc_settings['shipping_rate'] ?? null,
'shipping_time' => $mc_settings['shipping_time'] ?? null,
];
}
/**
* Whether the global shipping method can be synced to Merchant Center.
*
* Used by add_market()/delete_market() to decide whether the change needs to be
* pushed to Google. Mirrors the per-market predicate in has_syncable_markets().
*
* @return bool
*/
private function global_shipping_is_syncable(): bool {
$global = $this->global_shipping_method();
return $this->is_syncable_shipping_method( $global['shipping_rate'], $global['shipping_time'] );
}
/**
* Whether a shipping method (rate + time) is syncable to Merchant Center.
*
* @param string|null $rate The shipping_rate value.
* @param string|null $time The shipping_time value.
*
* @return bool True when the rate is `flat` or `automatic` and the time is `flat`.
*/
private function is_syncable_shipping_method( $rate, $time ): bool {
return in_array( $rate, [ 'flat', 'automatic' ], true ) && 'flat' === $time;
}
/**
* Returns shipping rates, fetched lazily and cached on the service instance.
*
* The container shares MarketService across a request, so this cache lives for
* the request and is discarded when the instance is destroyed.
*
* @return array
*/
private function get_cached_shipping_rates(): array {
if ( null === $this->cached_shipping_rates ) {
$rates = $this->shipping_rate_query->get_all_shipping_rates();
$this->cached_shipping_rates = is_array( $rates ) ? $rates : [];
}
return $this->cached_shipping_rates;
}
/**
* Returns a country's row from a shipping query, or null.
*
* Neither table has a unique index on country, and the read side lets the last duplicate
* win, so this matches that rather than the first.
*
* @param QueryInterface $query Shipping rate or time query.
* @param string $country ISO 3166-1 alpha-2 country code.
*
* @return array|null
*/
private function find_shipping_row( QueryInterface $query, string $country ): ?array {
$found = null;
foreach ( $query->get_results() ?? [] as $row ) {
if ( $row['country'] === $country ) {
$found = $row;
}
}
return $found;
}
/**
* Returns the country a market's submitted shipping belongs to.
*
* @param string $id Market ID.
* @param array $config Submitted config.
*
* @return string ISO 3166-1 alpha-2 country code, or an empty string when there is none.
*/
private function resolve_market_shipping_country( string $id, array $config ): string {
if ( 'primary' === $id ) {
return $this->target_audience->get_main_target_country();
}
if ( ! empty( $config['country'] ) ) {
return (string) $config['country'];
}
return (string) ( $this->get_market( $id )['country'] ?? '' );
}
/**
* Rejects a delivery window whose minimum is past its maximum.
*
* Checked against the merged window, since either half can be submitted on its own, and
* before anything is written so a rejected payload leaves no partial state. Matches the
* rule the shipping-time endpoint applies.
*
* @param string $country ISO 3166-1 alpha-2 country code.
* @param array $shipping Submitted shipping values.
*
* @throws InvalidValue When the minimum delivery time is greater than the maximum.
*/
private function assert_shipping_window( string $country, array $shipping ): void {
$has_time = array_key_exists( 'flat_time', $shipping ) && null !== $shipping['flat_time'];
$has_max = array_key_exists( 'flat_max_time', $shipping ) && null !== $shipping['flat_max_time'];
if ( ! $has_time && ! $has_max ) {
return;
}
$existing = '' === $country ? null : $this->find_shipping_row( $this->shipping_time_query, $country );
$time = $has_time ? (int) $shipping['flat_time'] : (int) ( $existing['time'] ?? 0 );
$max_time = $has_max ? (int) $shipping['flat_max_time'] : (int) ( $existing['max_time'] ?? 0 );
// A zero maximum is the unset state, so only a real window is checked.
if ( $max_time > 0 && $time > $max_time ) {
throw new InvalidValue(
sprintf( 'The minimum delivery time (%1$d) cannot be greater than the maximum (%2$d).', $time, $max_time )
);
}
}
/**
* Writes a market's submitted shipping values to the rows for its country.
*
* Each of the four values is optional: only the ones present are written, so a caller can
* send just a rate or just a delivery window. A null reads as "not configured", which is what
* the read side reports for a country with no row, so sending one back leaves the stored value
* alone rather than writing a zero. The exception is free_shipping_threshold, whose null is
* the value itself and therefore clears the threshold.
*
* The rate row carries a currency the payload has no field for, so a new row takes the store
* currency, matching what the shipping-rate endpoint defaults to.
*
* @param string $country ISO 3166-1 alpha-2 country code.
* @param array $shipping Submitted shipping values.
*/
private function save_market_shipping( string $country, array $shipping ): void {
if ( '' === $country ) {
return;
}
$wrote = $this->save_market_shipping_rate( $country, $shipping );
$wrote = $this->save_market_shipping_time( $country, $shipping ) || $wrote;
if ( $wrote && $this->global_shipping_is_syncable() ) {
$this->schedule_shipping_sync();
}
}
/**
* Writes the rate half of a submitted shipping payload.
*
* @param string $country ISO 3166-1 alpha-2 country code.
* @param array $shipping Submitted shipping values.
*
* @return bool Whether a row was written.
*/
private function save_market_shipping_rate( string $country, array $shipping ): bool {
$has_rate = array_key_exists( 'flat_rate', $shipping ) && null !== $shipping['flat_rate'];
$has_threshold = array_key_exists( 'free_shipping_threshold', $shipping );
if ( ! $has_rate && ! $has_threshold ) {
return false;
}
$existing = $this->find_shipping_row( $this->shipping_rate_query, $country );
// Clearing a threshold on a country that has no row leaves it with none, rather than
// creating one that declares free shipping at zero cost.
if ( null === $existing && ! $has_rate && null === ( $shipping['free_shipping_threshold'] ?? null ) ) {
return false;
}
$options = is_array( $existing['options'] ?? null ) ? $existing['options'] : [];
if ( $has_threshold ) {
if ( null === $shipping['free_shipping_threshold'] ) {
unset( $options['free_shipping_threshold'] );
} else {
$options['free_shipping_threshold'] = (float) $shipping['free_shipping_threshold'];
}
}
$data = [
'country' => $country,
'currency' => $existing['currency'] ?? get_woocommerce_currency(),
'rate' => $has_rate ? (float) $shipping['flat_rate'] : ( $existing['rate'] ?? 0 ),
'options' => $options,
];
if ( $existing ) {
$this->shipping_rate_query->update( $data, [ 'id' => $existing['id'] ] );
} else {
$this->shipping_rate_query->insert( $data );
}
$this->forget_shipping_rates();
return true;
}
/**
* Writes the delivery-time half of a submitted shipping payload.
*
* @param string $country ISO 3166-1 alpha-2 country code.
* @param array $shipping Submitted shipping values.
*
* @return bool Whether a row was written.
*/
private function save_market_shipping_time( string $country, array $shipping ): bool {
$has_time = array_key_exists( 'flat_time', $shipping ) && null !== $shipping['flat_time'];
$has_max_time = array_key_exists( 'flat_max_time', $shipping ) && null !== $shipping['flat_max_time'];
if ( ! $has_time && ! $has_max_time ) {
return false;
}
$existing = $this->find_shipping_row( $this->shipping_time_query, $country );
$data = [
'country' => $country,
'time' => $has_time ? (int) $shipping['flat_time'] : (int) ( $existing['time'] ?? 0 ),
'max_time' => $has_max_time ? (int) $shipping['flat_max_time'] : (int) ( $existing['max_time'] ?? 0 ),
];
if ( $existing ) {
$this->shipping_time_query->update( $data, [ 'id' => $existing['id'] ] );
} else {
$this->shipping_time_query->insert( $data );
}
$this->forget_shipping_times();
return true;
}
/**
* Discards the shipping-rate reads cached for this request.
*/
private function forget_shipping_rates(): void {
$this->cached_shipping_rates = null;
$this->shipping_rate_query->reset_results();
}
/**
* Discards the shipping-time reads cached for this request.
*
* Both layers memoize, so the query object has to be reset too or it re-serves the rows
* it read before the write.
*/
private function forget_shipping_times(): void {
$this->cached_shipping_times = null;
$this->shipping_time_query->reset_results();
}
/**
* Returns shipping times, fetched lazily and cached on the service instance.
*
* Cached for the same reason as the rates: get_market_shipping() runs once per market,
* so an uncached read would cost one query per market in a markets response.
*
* @return array
*/
private function get_cached_shipping_times(): array {
if ( null === $this->cached_shipping_times ) {
$times = $this->shipping_time_query->get_all_shipping_times();
$this->cached_shipping_times = is_array( $times ) ? $times : [];
}
return $this->cached_shipping_times;
}
/**
* Fills in a secondary market config's missing locale values.
*
* A missing `language` defaults to the site primary language and a missing
* `currency` to the site primary currency (the WooCommerce store currency
* when no multilingual integration is active). Present values, including
* empty arrays, are left untouched.
*
* @param array $config The market config.
*
* @return array The config with locale defaults applied.
*/
private function apply_locale_defaults( array $config ): array {
if ( ! isset( $config['language'] ) ) {
$config['language'] = [ $this->get_site_primary_language() ];
}
if ( ! isset( $config['currency'] ) ) {
$config['currency'] = [ $this->get_site_primary_currency() ];
}
return $config;
}
/**
* Validates that a secondary market config contains the required keys.
*
* @param array $config The config to validate (full or merged).
* @param bool $validate_currency_source Whether to require the market's currency to be
* producible or covered by an exchange rate. True
* for creates and for updates that touch the
* currency or exchange rate; false for partial
* updates that leave them untouched, so unrelated
* edits of markets stored before this validation
* existed keep working.
*
* @throws InvalidValue When a required key is missing or invalid.
*/
private function validate_secondary_market_config( array $config, bool $validate_currency_source = true ): void {
if ( empty( $config['country'] ) || ! is_string( $config['country'] ) ) {
throw InvalidValue::is_empty( 'country' );
}
foreach ( [ 'language', 'currency' ] as $key ) {
if ( ! isset( $config[ $key ] ) ) {
throw InvalidValue::is_empty( $key );
}
if ( ! is_array( $config[ $key ] ) ) {
throw InvalidValue::not_array( $key );
}
}
if ( isset( $config['exchange_rate'] ) && ( ! is_numeric( $config['exchange_rate'] ) || (float) $config['exchange_rate'] <= 0 ) ) {
throw new InvalidValue( 'The exchange_rate must be a number greater than zero.' );
}
// A currency the site cannot produce is only valid with a fixed
// exchange rate to produce it at sync time.
if ( $validate_currency_source && empty( $config['exchange_rate'] ) ) {
$producible = array_column( $this->get_currencies(), 'code' );
if ( empty( $producible ) ) {
$producible = [ get_woocommerce_currency() ];
}
foreach ( $config['currency'] as $currency_code ) {
if ( ! in_array( (string) $currency_code, $producible, true ) ) {
throw new InvalidValue(
sprintf( 'The currency "%s" cannot be produced by this site. Configure an exchange rate for the market or use a producible currency.', $currency_code )
);
}
}
}
// Every currency must be enabled for at least one selected language, else no feed could
// ever be generated for it. Normalise through get_market_currencies() so the check runs on
// the same uppercased codes feed generation uses, otherwise a lowercase currency slips past.
$languages = $this->normalise_language_codes( array_map( 'strval', $config['language'] ) );
$currencies = $this->get_market_currencies( $config );
if ( ! empty( $currencies ) && ! empty( $languages ) ) {
$enabled = [];
foreach ( $languages as $language ) {
$enabled = array_merge( $enabled, $this->wpml->get_currencies_enabled_for_language( $currencies, (string) $language ) );
}
$enabled = array_values( array_unique( $enabled ) );
if ( ! empty( array_diff( $currencies, $enabled ) ) ) {
throw InvalidValue::not_in_allowed_list( 'currency', $enabled );
}
}
}
/**
* Returns the site's default language as an ISO 639-1 code, independent of
* any multilingual integration state.
*
* Used to build the fallback language entry in get_languages(); must not
* call get_site_primary_language(), which resolves through get_languages()
* and would recurse.
*
* @return string
*/
private function get_default_site_language_code(): string {
return substr( get_locale(), 0, 2 );
}
/**
* Returns a human-readable label for the site's default language.
*
* @return string
*/
private function get_default_site_language_label(): string {
$locale = get_locale();
if ( class_exists( Locale::class ) ) {
return Locale::getDisplayLanguage( $locale, $locale );
}
// en_US isn't provided by the translations API.
if ( 'en_US' === $locale ) {
return 'English';
}
require_once ABSPATH . 'wp-admin/includes/translation-install.php';
return wp_get_available_translations()[ $locale ]['native_name'] ?? $locale;
}
/**
* Returns the site primary language code (ISO 639-1).
*
* Uses the WPML default language when multilingual support is active,
* otherwise falls back to the WordPress locale.
*
* @return string
*/
private function get_site_primary_language(): string {
if ( $this->has_multilingual_support() ) {
$from_wpml = $this->resolve_primary_language_from_wpml();
if ( '' !== $from_wpml ) {
return $from_wpml;
}
}
return substr( get_locale(), 0, 2 );
}
/**
* Returns the site primary currency code (ISO 4217).
*
* Uses the WooCommerce store currency when it appears in the WPML currency
* list, otherwise the first WPML currency, when multilingual support is active.
*
* @return string
*/
private function get_site_primary_currency(): string {
if ( $this->has_multilingual_support() ) {
$from_wpml = $this->resolve_primary_currency_from_wpml();
if ( '' !== $from_wpml ) {
return $from_wpml;
}
}
return get_woocommerce_currency();
}
/**
* Resolves the primary language from WPML integration data.
*
* @return string
*/
private function resolve_primary_language_from_wpml(): string {
$languages = $this->get_languages();
$default_code = $this->wpml->get_default_language_code();
foreach ( $languages as $language ) {
if ( isset( $language['code'] ) && $default_code === $language['code'] ) {
return $language['code'];
}
}
return $languages[0]['code'] ?? '';
}
/**
* Resolves the primary currency from WPML integration data.
*
* @return string
*/
private function resolve_primary_currency_from_wpml(): string {
$currencies = $this->get_currencies();
$store_currency = get_woocommerce_currency();
foreach ( $currencies as $currency ) {
if ( isset( $currency['code'] ) && $store_currency === $currency['code'] ) {
return $currency['code'];
}
}
return $currencies[0]['code'] ?? '';
}
/**
* Removes a country from the primary feed's TargetAudience countries list.
*
* Idempotent — no-op if the country is already absent.
*
* An "all countries" audience carries no explicit list, so a country cannot be taken out of
* it without first materialising it into one. Materialising is one-way: the store stops
* picking up countries Merchant Center adds to the supported list later.
*
* @param string $country ISO 3166-1 alpha-2 country code.
*/
private function remove_country_from_target_audience( string $country ): void {
$target_audience = $this->options->get( OptionsInterface::TARGET_AUDIENCE, [] );
if ( 'all' === strtolower( (string) ( $target_audience['location'] ?? '' ) ) ) {
$target_audience['location'] = 'selected';
$target_audience['countries'] = $this->target_audience->get_target_countries();
}
if ( empty( $target_audience['countries'] ) || ! is_array( $target_audience['countries'] ) ) {
return;
}
$filtered = array_values(
array_filter(
$target_audience['countries'],
function ( $c ) use ( $country ) {
return $c !== $country;
}
)
);
if ( count( $filtered ) !== count( $target_audience['countries'] ) ) {
$target_audience['countries'] = $filtered;
$this->options->update( OptionsInterface::TARGET_AUDIENCE, $target_audience );
}
}
/**
* Extends existing shipping settings to a newly targeted country.
*
* Copies the primary market's shipping rate and time rows for the country
* when no row exists for it yet. Existing rows are never overwritten, so a
* country that is already covered keeps its configured values.
*
* @param string $country ISO 3166-1 alpha-2 country code of the added market.
*/
private function extend_shipping_to_country( string $country ): void {
$has_rate_row = false;
$rate_rows = $this->shipping_rate_query->get_results() ?? [];
foreach ( $rate_rows as $row ) {
if ( $row['country'] === $country ) {
$has_rate_row = true;
break;
}
}
if ( ! $has_rate_row ) {
$this->adopt_primary_rate_for_country( $country );
}
$has_time_row = false;
$time_rows = $this->shipping_time_query->get_results() ?? [];
foreach ( $time_rows as $row ) {
if ( $row['country'] === $country ) {
$has_time_row = true;
break;
}
}
if ( ! $has_time_row ) {
$this->adopt_primary_time_for_country( $country );
}
}
/**
* Overwrites the deleted country's shipping rate row with the primary market's values.
*
* Reads the full ShippingRateTable once and filters in PHP. When both rows exist the target
* row is updated. When only the primary row exists the target row is inserted. When only the
* target row exists it is deleted so the existing shipping sync pipeline can recompute the
* country's rate on the next run. When neither row exists nothing happens.
*
* @param string $country ISO 3166-1 alpha-2 country code of the deleted secondary market.
*/
private function adopt_primary_rate_for_country( string $country ): void {
$primary_country = $this->target_audience->get_main_target_country();
$rows = $this->shipping_rate_query->get_results() ?? [];
$primary_row = null;
$existing_row = null;
foreach ( $rows as $row ) {
if ( $row['country'] === $primary_country ) {
$primary_row = $row;
}
if ( $row['country'] === $country ) {
$existing_row = $row;
}
}
if ( $primary_row ) {
$primary_options = $primary_row['options'] ?? null;
$data = [
'country' => $country,
'currency' => $primary_row['currency'],
'rate' => $primary_row['rate'],
'options' => is_array( $primary_options ) ? $primary_options : [],
];
if ( $existing_row ) {
$this->shipping_rate_query->update( $data, [ 'id' => $existing_row['id'] ] );
} else {
$this->shipping_rate_query->insert( $data );
}
$this->forget_shipping_rates();
return;
}
if ( $existing_row ) {
$this->shipping_rate_query->delete( 'country', $country );
$this->forget_shipping_rates();
}
}
/**
* Overwrites the deleted country's shipping time row with the primary market's values.
*
* Same shape as adopt_primary_rate_for_country against ShippingTimeQuery / ShippingTimeTable.
*
* @param string $country ISO 3166-1 alpha-2 country code of the deleted secondary market.
*/
private function adopt_primary_time_for_country( string $country ): void {
$primary_country = $this->target_audience->get_main_target_country();
$rows = $this->shipping_time_query->get_results() ?? [];
$primary_row = null;
$existing_row = null;
foreach ( $rows as $row ) {
if ( $row['country'] === $primary_country ) {
$primary_row = $row;
}
if ( $row['country'] === $country ) {
$existing_row = $row;
}
}
if ( $primary_row ) {
$data = [
'country' => $country,
'time' => $primary_row['time'],
'max_time' => $primary_row['max_time'],
];
if ( $existing_row ) {
$this->shipping_time_query->update( $data, [ 'id' => $existing_row['id'] ] );
} else {
$this->shipping_time_query->insert( $data );
}
$this->forget_shipping_times();
return;
}
if ( $existing_row ) {
$this->shipping_time_query->delete( 'country', $country );
$this->forget_shipping_times();
}
}
/**
* Restores a country to the primary feed's TargetAudience countries list.
*
* Idempotent — no-op if the country is already present.
*
* @param string $country ISO 3166-1 alpha-2 country code.
*/
private function restore_country_to_target_audience( string $country ): void {
$target_audience = $this->options->get( OptionsInterface::TARGET_AUDIENCE, [] );
// An "all countries" audience already targets it, and appending to the list it does not
// read would leave the location and the list saying different things.
if ( 'all' === strtolower( (string) ( $target_audience['location'] ?? '' ) ) ) {
return;
}
if ( ! isset( $target_audience['countries'] ) || ! is_array( $target_audience['countries'] ) ) {
$target_audience['countries'] = [];
}
if ( ! in_array( $country, $target_audience['countries'], true ) ) {
$target_audience['countries'][] = $country;
$this->options->update( OptionsInterface::TARGET_AUDIENCE, $target_audience );
}
}
/**
* Whether a country is currently targeted by the primary feed.
*
* Resolved through TargetAudience so an "all countries" audience, which stores no explicit
* list, still reports its countries as targeted. Every caller uses this to decide whether a
* country belongs to the primary feed, and a wrong answer strips the shipping rows of a
* country that is still selling.
*
* @param string $country ISO 3166-1 alpha-2 country code.
*
* @return bool
*/
private function is_country_in_target_audience( string $country ): bool {
return in_array( $country, $this->target_audience->get_target_countries(), true );
}
/**
* Removes a country's shipping rate and time rows.
*
* Used when a country is no longer targeted by any market — a deleted
* secondary market's country not returning to the primary market, or a
* country dropped from the primary market's target audience entirely:
* with no rows and no target audience entry, the country is omitted from
* the next shipping settings payload, and Google deletes its shipping
* service because shippingsettings.update replaces the full resource
* ("any fields that are not provided are deleted").
*
* Public so callers outside this service (e.g. TargetAudienceController)
* can clean up a country's rows when they remove it from the primary
* market's target audience.
*
* @param string $country ISO 3166-1 alpha-2 country code.
*/
public function remove_shipping_rows_for_country( string $country ): void {
$this->shipping_rate_query->delete( 'country', $country );
$this->shipping_time_query->delete( 'country', $country );
$this->forget_shipping_rates();
$this->forget_shipping_times();
}
}
Red, Black and Silver Freshwater Pearl Necklace with Sterling Silver ClaspSkip to content
18″ Red, Black, and Silver Freshwater Pearl Necklace
$169.99Original price was: $169.99.$129.99Current price is: $129.99.
18″ Red, Black, and Silver Freshwater Pearl Necklace with 7-7.5mm pearls. Hand strung, double knotted and finished with sterling silver clasp. The perfect accessory for University of New Mexico sports fans. GO LOBOS!
Reviews
There are no reviews yet.