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();
},
'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();
}
// 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 );
$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.
$cfg = class_exists( 'Xpay_Storefront_Widget' )
? Xpay_Storefront_Widget::widget_config( $slug )
: 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 ) {
$cached = get_transient( 'xpay_wc_storefront_entitlement' );
if ( false !== $cached ) {
return 'unknown' === $cached ? 'unknown' : ( $cached ? 'yes' : 'no' );
}
$entitled = self::fetch_storefront_entitlement( $slug );
if ( null === $entitled ) {
set_transient( 'xpay_wc_storefront_entitlement', 'unknown', MINUTE_IN_SECONDS );
return 'unknown';
}
set_transient( 'xpay_wc_storefront_entitlement', $entitled ? 1 : 0, 6 * HOUR_IN_SECONDS );
return $entitled ? 'yes' : 'no';
}
/**
* 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 );
$res = wp_remote_get( $url, array( 'timeout' => 4 ) );
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' );
}
}