` constant in wp-config.php — hard off, host/dev escape hatch. * 2. `xpay_wc_module_enabled` filter — programmatic, per module id. * 3. `$args['enabled']` callable — the module's own precondition (e.g. is_admin()). * * @package AgenticCommerceForWooCommerce */ defined( 'ABSPATH' ) || exit; /** * Class Xpay_Modules. */ class Xpay_Modules { /** * Registered modules, in registration order. Boot order IS registration order. * * @var array */ private static $modules = array(); /** * Ids of modules that threw during boot, mapped to the error message. * * @var array */ private static $failed = array(); /** * Ids that booted cleanly. * * @var string[] */ private static $booted = array(); /** * True once boot_all() has run, so late registrations are an obvious no-op * rather than a silent one. * * @var bool */ private static $booted_all = false; /** * Register a module. * * @param string $id Stable, lowercase, hyphenless-or-hyphenated identifier. * Used for the kill-switch constant, so keep it stable — * renaming an id silently discards a merchant's wp-config * override. * @param callable $boot Invoked once, during boot_all(). * @param array $args { * Optional. * * @type callable $enabled Precondition; module is skipped when it returns false. * @type bool $critical When true, a failure is surfaced more loudly in the * admin notice. Never changes storefront behaviour. * } * @return void */ public static function register( $id, $boot, $args = array() ) { if ( self::$booted_all ) { // Registering after boot would never run. Fail loudly for us, silently // for the merchant. self::debug_log( sprintf( 'module "%s" registered after boot_all(); ignored', $id ) ); return; } self::$modules[] = array( 'id' => $id, 'boot' => $boot, 'args' => $args, ); } /** * Boot every registered module, in registration order. * * Ordering is load-bearing: several subsystems read options that an earlier * one writes. This method must not reorder, parallelise, or lazily defer. * * @return void */ public static function boot_all() { self::$booted_all = true; foreach ( self::$modules as $module ) { $id = $module['id']; if ( ! self::is_enabled( $id, $module['args'] ) ) { continue; } try { call_user_func( $module['boot'] ); self::$booted[] = $id; } catch ( Throwable $e ) { // Throwable, not Exception: a TypeError or a call to an undefined // method on a half-deployed upgrade is exactly the case this exists // for, and those are Errors, not Exceptions. self::$failed[ $id ] = $e->getMessage(); self::debug_log( sprintf( 'module "%s" failed to boot: %s in %s:%d', $id, $e->getMessage(), $e->getFile(), $e->getLine() ) ); } } if ( self::$failed && is_admin() ) { add_action( 'admin_notices', array( __CLASS__, 'render_failure_notice' ) ); } } /** * Whether a module should boot. * * @param string $id Module id. * @param array $args Registration args. * @return bool */ private static function is_enabled( $id, $args ) { $constant = 'XPAY_WC_DISABLE_' . strtoupper( str_replace( '-', '_', $id ) ); if ( defined( $constant ) && constant( $constant ) ) { return false; } if ( isset( $args['enabled'] ) && is_callable( $args['enabled'] ) ) { try { if ( ! call_user_func( $args['enabled'] ) ) { return false; } } catch ( Throwable $e ) { // A precondition that throws is treated as "not ready" — the same // fail-closed posture as a boot failure. self::$failed[ $id ] = $e->getMessage(); return false; } } /** * Filter whether an xpay module boots. * * @param bool $enabled Whether to boot. * @param string $id Module id. */ return (bool) apply_filters( 'xpay_wc_module_enabled', true, $id ); } /** * Ids that booted cleanly. Used by diagnostics and by the golden-master tests. * * @return string[] */ public static function booted() { return self::$booted; } /** * Failed module ids mapped to their error message. * * @return array */ public static function failures() { return self::$failed; } /** * Whether a given module booted. Lets one module degrade gracefully when an * optional sibling is absent, instead of assuming it is there. * * @param string $id Module id. * @return bool */ public static function is_booted( $id ) { return in_array( $id, self::$booted, true ); } /** * One quiet admin notice naming what failed. * * Deliberately vague about internals and deliberately not dismissible-forever: * a silently degraded plugin is how the widget funnel stayed broken for months. * * @return void */ public static function render_failure_notice() { if ( ! current_user_can( 'manage_woocommerce' ) ) { return; } $ids = implode( ', ', array_keys( self::$failed ) ); echo '

'; printf( /* translators: %s: comma-separated list of internal feature names. */ esc_html__( 'xpay: part of the plugin could not start (%s). Your store is unaffected and the rest of the plugin is running normally. If this persists, email merchants@xpay.sh and mention this message.', 'agentic-commerce-for-woocommerce' ), esc_html( $ids ) ); echo '

'; } /** * Log only when WP_DEBUG is on. The plugin must stay silent in a normal * production error log. * * @param string $message Message. * @return void */ private static function debug_log( $message ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( '[xpay] ' . $message ); } } }