woocommerce_active() ) { add_action( 'admin_notices', array( $this, 'woocommerce_missing_notice' ) ); return; } // BEFORE any subsystem is wired up: the version bump runs one-time upgrade // migrations that write options those subsystems then read. Wiring first and // migrating second would hand every subsystem the pre-migration state for the // duration of the upgrade request. $this->maybe_handle_version_bump(); $this->register_modules(); Xpay_Modules::boot_all(); add_action( 'admin_init', array( $this, 'maybe_redirect_after_activation' ) ); } /** * Register every subsystem with the fault-isolating registry. * * ⛔ Registration order IS boot order, and it is identical to the inline * sequence this replaced. Several subsystems read options an earlier one * writes (and Order_Events' cron registration must follow its instance), * so this list must not be reordered or alphabetised. * * Each entry is a straight lift of the previous call — no subsystem's own * code changed. What changed is only what happens when one of them throws. * * @return void */ private function register_modules() { $m = array( 'rest' => array( 'Xpay_REST', 'instance' ), 'robots' => array( 'Xpay_Robots', 'instance' ), 'schema' => array( 'Xpay_Schema', 'instance' ), 'cart' => array( 'Xpay_Cart', 'instance' ), 'attribution' => array( 'Xpay_Attribution', 'instance' ), // Order_Events registers its cron immediately after instantiating; // kept as one unit so the pair can never be split by a kill switch. 'order-events' => static function () { Xpay_Order_Events::instance(); Xpay_Order_Events::register_cron(); }, // ChatGPT Ads click capture, pixel and purchase backup. After // attribution + order-events so it never runs ahead of either. 'chatgpt-pixel' => array( 'Xpay_ChatGPT_Pixel', 'instance' ), 'webhooks' => array( 'Xpay_Webhooks', 'instance' ), 'settings' => array( 'Xpay_Settings', 'instance' ), 'emitter-probe' => array( 'Xpay_Emitter_Probe', 'register_cron' ), 'deflection' => array( 'Xpay_Deflection', 'register' ), 'blog-proxy' => array( 'Xpay_Blog_Proxy', 'register' ), 'agent-analytics' => array( 'Xpay_Agent_Analytics', 'register' ), 'admin-rest' => array( 'Xpay_Admin_REST', 'instance' ), 'widget' => array( 'Xpay_Widget', 'instance' ), 'storefront-widget' => array( 'Xpay_Storefront_Widget', 'instance' ), 'shop-assist' => array( 'Xpay_Shop_Assist_Page', 'instance' ), 'content-pages' => array( 'Xpay_Content_Pages', 'instance' ), // Local-only statistics. Registered AFTER agent-analytics so its // shutdown hook (priority 1) always runs behind the send path's // (priority 0) and can never perturb it. Sends nothing, ever — which // is precisely why it is not behind the telemetry opt-in. 'local-stats' => array( 'Xpay_Local_Stats', 'register' ), ); foreach ( $m as $id => $boot ) { Xpay_Modules::register( $id, $boot ); } Xpay_Modules::register( 'consent', array( 'Xpay_Consent', 'instance' ), array( 'enabled' => 'is_admin' ) ); // The xpay✦ Commerce menu and its screens. Admin-only, and registered // last so a failure here can never affect the storefront. Xpay_Modules::register( 'admin-ui', array( 'Xpay_Admin', 'register' ), array( 'enabled' => 'is_admin' ) ); } private function maybe_handle_version_bump() { $stored = get_option( 'xpay_wc_installed_version' ); if ( $stored === XPAY_WC_VERSION ) { return; } // Version changed — re-flush rewrite rules so any added or removed // discovery routes take effect without requiring a deactivate/reactivate. update_option( 'xpay_wc_flush_rewrites', 1 ); $this->seed_faq_visibility( $stored ); update_option( 'xpay_wc_installed_version', XPAY_WC_VERSION ); } /** * One-time seed of the visible-FAQ switch, for stores upgrading from < 0.6.0. * * 0.6.0 puts the shopper-facing FAQ block behind `xpay_wc_faq_visible`, default * OFF, owned by the backend. Without this seed, a store that has the block today * would lose it the moment it auto-updates and not get it back until our next * push — a window where their PDPs silently change. So we carry the existing * state across the upgrade: a store with approved FAQs already pushed to it keeps * rendering them, and nobody else acquires storefront UI they never had. * * Deliberately keyed on local state, not on a merchant allowlist — this plugin is * public GPL on WP.org and customer names have no business being in it. The two * coincide: FAQs are only ever pushed to entitled merchants, so "has approved FAQs" * IS the entitled set. * * Runs once. After this, the backend's `set_product_faqs` push is the only writer, * and it can turn any store on or off with no plugin release. */ private function seed_faq_visibility( $stored_version ) { // Fresh installs (no prior version recorded) start OFF, full stop. if ( ! $stored_version ) { return; } if ( version_compare( (string) $stored_version, '0.6.0', '>=' ) ) { return; } // Never overwrite a value the backend has already pushed. if ( null !== get_option( 'xpay_wc_faq_visible', null ) ) { return; } $faqs = (string) get_option( 'xpay_wc_product_faqs', '' ); if ( '' === $faqs || '[]' === $faqs || '{}' === $faqs ) { return; } $decoded = json_decode( $faqs, true ); if ( ! is_array( $decoded ) || empty( $decoded ) ) { return; } update_option( 'xpay_wc_faq_visible', 1 ); } public function maybe_redirect_after_activation() { if ( ! get_transient( 'xpay_wc_post_activation_redirect' ) ) { return; } delete_transient( 'xpay_wc_post_activation_redirect' ); // Don't redirect on bulk-activate (multiple plugins activated at once). if ( isset( $_GET['activate-multi'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return; } if ( wp_safe_redirect( admin_url( 'options-general.php?page=agentic-commerce-for-woocommerce' ) ) ) { exit; } } public function woocommerce_active() { if ( class_exists( 'WooCommerce' ) ) { return true; } $active = (array) get_option( 'active_plugins', array() ); if ( is_multisite() ) { $active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) ); } return in_array( 'woocommerce/woocommerce.php', $active, true ); } public function woocommerce_missing_notice() { echo '

'; esc_html_e( 'Agentic Commerce for WooCommerce requires WooCommerce to be installed and active.', 'agentic-commerce-for-woocommerce' ); echo '

'; } public static function on_activate() { if ( ! get_option( 'xpay_wc_site_token' ) ) { update_option( 'xpay_wc_site_token', wp_generate_password( 32, false ) ); } // Force a rewrite-rule flush after we register routes on next init. update_option( 'xpay_wc_flush_rewrites', 1 ); $first_time = ! (bool) get_option( 'xpay_wc_first_activated_at' ); if ( $first_time ) { update_option( 'xpay_wc_first_activated_at', time() ); } // Redirect to Settings → xpay on the very next admin page load if the // merchant hasn't connected yet. Covers both fresh installs and upgrades // where the merchant never got around to connecting. if ( ! self::is_connected() ) { set_transient( 'xpay_wc_post_activation_redirect', 1, 60 ); } // Prime the emitter-probe cache shortly after activation so the // first /llms.txt or /.well-known/* request sees a populated // detection result. Skips silently if Xpay_Emitter_Probe isn't // loaded yet (defensive — the require_once chain in this file // loads it before this method runs, but staying paranoid). if ( class_exists( 'Xpay_Emitter_Probe' ) ) { Xpay_Emitter_Probe::prime_on_activation(); } // Belt-and-suspenders: Xpay_Telemetry::track() already short-circuits // when telemetry is not opted in, but checking is_enabled() at the // call site makes the activation path trivially auditable as // network-silent on fresh installs without reading the Telemetry // class body. if ( class_exists( 'Xpay_Telemetry' ) && Xpay_Telemetry::is_enabled() ) { Xpay_Telemetry::track( 'plugin_activated', array( 'first_time' => $first_time, ) ); } } public static function on_deactivate() { flush_rewrite_rules(); if ( class_exists( 'Xpay_Shop_Assist_Page' ) ) { Xpay_Shop_Assist_Page::clear_cron(); } if ( class_exists( 'Xpay_Content_Pages' ) ) { Xpay_Content_Pages::clear_cron(); } if ( class_exists( 'Xpay_Emitter_Probe' ) ) { Xpay_Emitter_Probe::unregister_cron(); } if ( class_exists( 'Xpay_Deflection' ) ) { Xpay_Deflection::unregister_cron(); } if ( class_exists( 'Xpay_Blog_Proxy' ) ) { Xpay_Blog_Proxy::unregister_cron(); } if ( class_exists( 'Xpay_Agent_Analytics' ) ) { Xpay_Agent_Analytics::unregister_cron(); } if ( class_exists( 'Xpay_Order_Events' ) ) { Xpay_Order_Events::unregister_cron(); } if ( class_exists( 'Xpay_ChatGPT_Pixel' ) ) { Xpay_ChatGPT_Pixel::unregister_cron(); } if ( class_exists( 'Xpay_Storefront_Widget' ) ) { wp_clear_scheduled_hook( Xpay_Storefront_Widget::REFRESH_HOOK ); } // Belt-and-suspenders is_enabled() guard — see on_activate() above. if ( class_exists( 'Xpay_Telemetry' ) && Xpay_Telemetry::is_enabled() ) { Xpay_Telemetry::track( 'plugin_deactivated', array( 'was_connected' => self::is_connected(), ) ); } } public static function is_connected() { return (bool) get_option( 'xpay_wc_merchant_slug' ); } public static function merchant_slug() { return (string) get_option( 'xpay_wc_merchant_slug', '' ); } /** * Coerce a config value that may be a localized map into a single string for * server-side rendering. The backend may send a field as { "fr": "…", "en": "…", * "default": "…" }; rendering that array directly yields the literal "Array" * (and a PHP notice). Prefers the store locale, then `default`, then `en`, then * any non-empty string; falls back to $fallback. * * @param mixed $val String or localized-map value. * @param string $fallback Returned when nothing usable is present. * @return string */ public static function localized_value( $val, $fallback = '' ) { if ( is_string( $val ) ) { // An empty/whitespace string must fall through to $fallback, not return // '' — the widget passes the store name as the fallback precisely so an // unset or empty displayName renders it (prior behavior). $trimmed = trim( $val ); return '' !== $trimmed ? $trimmed : $fallback; } if ( is_array( $val ) ) { $lang = substr( (string) get_locale(), 0, 2 ); // 'fr_FR' -> 'fr' foreach ( array( $lang, 'default', 'en' ) as $key ) { if ( ! empty( $val[ $key ] ) && is_string( $val[ $key ] ) ) { return trim( $val[ $key ] ); } } foreach ( $val as $candidate ) { if ( ! empty( $candidate ) && is_string( $candidate ) ) { return trim( $candidate ); } } } return $fallback; } public static function api_key() { return (string) get_option( 'xpay_wc_api_key', '' ); } /** * Should the AI Storefront Assistant render on this storefront? * * Two layers, both required. A backend pre-grant ALONE once surfaced the chat * bubble on a live storefront with no merchant notification — never again. * Entitlement is permission to use; consent is permission to render. * * ENTITLEMENT — is this merchant on a plan / pre-grant that includes the * add-on? Cached backend lookup with a local-toggle fallback * on API failure. TTL 6h. * * CONSENT — has the merchant explicitly opted in? Backend * widgetConfig.widgetEnabled (set via xpay-app dashboard) OR * the local wp-admin toggle xpay_wc_storefront_widget_enabled * (manual opt-in counts as consent). * * The wp-config constant XPAY_WC_STOREFRONT_WIDGET still hard-overrides both * layers for staging/dev. */ public static function is_storefront_widget_entitled() { if ( defined( 'XPAY_WC_STOREFRONT_WIDGET' ) ) { return (bool) XPAY_WC_STOREFRONT_WIDGET; } $slug = self::merchant_slug(); if ( '' === $slug ) { return false; } // RENDERING decision: an unknown collapses to the local toggle. Guessing // wrong here costs a missing chat bubble, and it self-heals on the next // successful fetch. (Compare storefront_entitlement_state(), where an // unknown must NOT collapse — see the note there.) $state = self::storefront_entitlement_lookup( $slug, true ); $entitled = ( 'yes' === $state ) || ( 'unknown' === $state && (bool) get_option( 'xpay_wc_storefront_widget_enabled', 0 ) ); if ( ! $entitled ) { return false; } // Local toggle = explicit consent (merchant flipped a switch in wp-admin). if ( (bool) get_option( 'xpay_wc_storefront_widget_enabled', 0 ) ) { return true; } // Backend dashboard consent — set via xpay-app's Storefront Assistant page. // // `true` = accept the last CONFIRMED config when the live fetch is // unavailable. Without it, one unreachable request makes this read // `widgetEnabled` off a [] and returns false, and the merchant's widget // vanishes from every page rendered in that window — which is how a // healthy store ends up serving the loader on only a fraction of its // pageviews. This is a RENDERING decision, so it may use stale data; // storefront_entitlement_state() (which can retract a live page) may not. $cfg = class_exists( 'Xpay_Storefront_Widget' ) ? Xpay_Storefront_Widget::widget_config( $slug, true ) : array(); return ! empty( $cfg['widgetEnabled'] ); } /** * Entitlement as a THREE-state value: 'yes' | 'no' | 'unknown'. * * ⛔ `unknown` is the whole point of this method, and it must never be * flattened into `no` by a caller that can RETRACT something. * * A merchant who enabled the assistant from the dashboard never touches the * local wp-admin toggle, so that option reads 0 for them. If a single 4-second * timeout is allowed to mean "not entitled", it becomes indistinguishable from * the backend genuinely saying no — and Xpay_Shop_Assist_Page::reconcile() * would draft their live, indexed shopper page because we briefly couldn't * reach our own API. * * Retraction must be driven by the backend SAYING no, never by our failing to * hear it. */ public static function storefront_entitlement_state() { if ( defined( 'XPAY_WC_STOREFRONT_WIDGET' ) ) { return XPAY_WC_STOREFRONT_WIDGET ? 'yes' : 'no'; } $slug = self::merchant_slug(); if ( '' === $slug ) { return 'no'; } return self::storefront_entitlement_lookup( $slug ); } /** * Cached entitlement lookup. Returns 'yes' | 'no' | 'unknown'. * * A real answer is cached for 6h. An unreachable backend caches 'unknown' for * 60s — long enough that a down API doesn't put a 4s fetch on every pageview, * nowhere near long enough to pin a GUESS for six hours. */ private static function storefront_entitlement_lookup( $slug, $allow_stale = false ) { $cached = get_transient( 'xpay_wc_storefront_entitlement' ); if ( false !== $cached ) { if ( 'unknown' !== $cached ) { return $cached ? 'yes' : 'no'; } // Breaker is set. The render path may still lean on a previously // CONFIRMED 'yes' rather than losing the widget for the window. return ( $allow_stale && 'yes' === self::last_known_good_entitlement() ) ? 'yes' : 'unknown'; } // Render path never blocks: serve the confirmed answer and refresh // out-of-band. A store with no confirmed answer yet (fresh install) // falls through to the blocking fetch, exactly as before. // // ⛔ `'yes' ===`, never merely "we have one". The first draft of this // returned $lkg unconditionally — including a stored 'no' — which is the // exact thing the comment below forbids: a merchant who buys the add-on // would be answered 'no' from an old option without the API ever being // asked, and reconcile() would then unpublish their page. A stale 'no' // must always fall through and ASK. // // ⛔ And nothing is written to the shared transient here. That key is // what storefront_entitlement_state() — the retraction-authoritative // reader — consumes; pinning a stale value into it would leak this // render-only shortcut into the caller that must never see stale data. if ( $allow_stale && 'yes' === self::last_known_good_entitlement() && self::lkg_entitlement_fresh_enough() ) { if ( class_exists( 'Xpay_Storefront_Widget' ) ) { Xpay_Storefront_Widget::schedule_refresh(); } return 'yes'; } $entitled = self::fetch_storefront_entitlement( $slug ); if ( null === $entitled ) { set_transient( 'xpay_wc_storefront_entitlement', 'unknown', MINUTE_IN_SECONDS ); // ⛔ ASYMMETRIC ON PURPOSE. A stored 'yes' may be reused, because the // worst case is a widget that lingers a few minutes past a // downgrade. A stored 'no' must NOT be reused as 'no' — that would // let our own failure to reach the API retract a merchant's widget // and (via reconcile) their live shopper page. Silence still means // 'unknown', which no caller is allowed to treat as a denial. return ( $allow_stale && 'yes' === self::last_known_good_entitlement() ) ? 'yes' : 'unknown'; } set_transient( 'xpay_wc_storefront_entitlement', $entitled ? 1 : 0, 6 * HOUR_IN_SECONDS ); update_option( self::LKG_ENTITLEMENT_OPTION, array( 'state' => $entitled ? 'yes' : 'no', 'slug' => (string) $slug, 'at' => time(), ), false ); return $entitled ? 'yes' : 'no'; } /** * Last entitlement the backend actually confirmed: 'yes' | 'no' | ''. * An option, not a transient — see Xpay_Storefront_Widget::widget_config(). */ private static function last_known_good_entitlement() { $v = get_option( self::LKG_ENTITLEMENT_OPTION, '' ); // Legacy scalar shape (pre-0.7.1 dev builds) still honoured. if ( 'yes' === $v || 'no' === $v ) { return $v; } if ( ! is_array( $v ) || empty( $v['state'] ) ) { return ''; } // Scoped to the slug it was confirmed for — reconnecting to a different // xpay account must not inherit the previous account's entitlement. if ( isset( $v['slug'] ) && $v['slug'] !== self::merchant_slug() ) { return ''; } return ( 'yes' === $v['state'] || 'no' === $v['state'] ) ? $v['state'] : ''; } /** * Is the stored entitlement recent enough to serve without asking? * * WP-Cron is not guaranteed to run, so without a ceiling a store with a dead * cron would serve one answer forever and a cancelled add-on would keep * rendering. Past the ceiling the render path blocks and asks, which is what * 0.7.0 did on every transient expiry. */ private static function lkg_entitlement_fresh_enough() { $v = get_option( self::LKG_ENTITLEMENT_OPTION, '' ); if ( ! is_array( $v ) || empty( $v['at'] ) ) { return false; // Unknown age (or legacy scalar) — ask. } $max = class_exists( 'Xpay_Storefront_Widget' ) ? Xpay_Storefront_Widget::LKG_MAX_AGE : 12 * HOUR_IN_SECONDS; return ( time() - (int) $v['at'] ) < $max; } /** * Entitlement from the API: true | false | NULL. * * NULL means "we could not ask" — it never means "no". */ private static function fetch_storefront_entitlement( $slug ) { $url = trailingslashit( XPAY_WC_AGENT_COMMERCE_BASE ) . 'widget/entitlement?slug=' . rawurlencode( $slug ); $timeout = max( 1, min( 15, (int) apply_filters( 'xpay_wc_storefront_fetch_timeout', 4 ) ) ); $res = wp_remote_get( $url, array( 'timeout' => $timeout ) ); if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) { return null; } $body = json_decode( (string) wp_remote_retrieve_body( $res ), true ); if ( ! is_array( $body ) ) { return null; } return ! empty( $body['storefront_widget'] ) || ! empty( $body['entitled'] ); } /** * Drop the cached entitlement so a just-purchased add-on lights up without * waiting for the 6h TTL. Call on settings save / add-on purchase webhook. */ public static function clear_storefront_entitlement_cache() { delete_transient( 'xpay_wc_storefront_entitlement' ); } /** * Is this merchant entitled to the "Content Engine" add-on? Same resolution * order as the storefront widget: * 1. XPAY_WC_CONTENT_ENGINE wp-config constant (staging/dev override). * 2. Cached backend entitlement (the add-on purchase incl. free grants), 6h. * 3. On backend failure, the local admin toggle xpay_wc_content_engine_enabled. * * NB: read the distinct `content_engine` flag from the entitlement endpoint — * NOT the generic `entitled` — so a storefront-widget grant never silently * publishes content pages. */ public static function is_content_engine_entitled() { if ( defined( 'XPAY_WC_CONTENT_ENGINE' ) ) { return (bool) XPAY_WC_CONTENT_ENGINE; } $slug = self::merchant_slug(); if ( '' === $slug ) { return false; } $cached = get_transient( 'xpay_wc_content_engine_entitlement' ); if ( false !== $cached ) { return (bool) $cached; } $entitled = self::fetch_content_engine_entitlement( $slug ); set_transient( 'xpay_wc_content_engine_entitlement', $entitled ? 1 : 0, 6 * HOUR_IN_SECONDS ); return $entitled; } private static function fetch_content_engine_entitlement( $slug ) { $url = trailingslashit( XPAY_WC_AGENT_COMMERCE_BASE ) . 'widget/entitlement?slug=' . rawurlencode( $slug ); $res = wp_remote_get( $url, array( 'timeout' => 4 ) ); if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) { return (bool) get_option( 'xpay_wc_content_engine_enabled', 0 ); } $body = json_decode( (string) wp_remote_retrieve_body( $res ), true ); return ! empty( $body['content_engine'] ); } public static function clear_content_engine_entitlement_cache() { delete_transient( 'xpay_wc_content_engine_entitlement' ); } }