$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 Clasp

18″ Red, Black, and Silver Freshwater Pearl Necklace

Original price was: $169.99.Current 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!

In stock