0, '1-10' => 10, '11-100' => 100, '101-1000' => 1000, ); /** * Label used for counts above the largest band. * * @var string */ const OVERFLOW_BAND = '1000+'; /** * Pro-fit thresholds — see pro_fit_segment() for where the numbers come from. */ const MULTI_REGISTER_MIN_POS_USERS = 4; const MULTI_GATEWAY_MIN_GATEWAYS = 3; const HIGH_VOLUME_ORDER_COUNT_BANDS = array( '101-1000', '1000+' ); /** * Classes that indicate a multi-currency plugin is running. * * WooCommerce core has no multi-currency concept, so presence has to be * inferred from the well-known implementations. This is a best-effort * signal for segmentation, not a contract — extend it via the * `woocommerce_pos_analytics_multi_currency` filter rather than assuming * the list is complete. * * @var string[] */ const MULTI_CURRENCY_CLASSES = array( 'WCML_Multi_Currency', // WPML WooCommerce Multilingual. 'WC_Aelia_CurrencySwitcher', // Aelia Currency Switcher. 'WOOMULTI_CURRENCY_F', // CURCY / WooCommerce Multi Currency. 'Alg_WC_Currency_Switcher', // Currency Switcher for WooCommerce. ); /** * Build the `site` group properties. * * Safe to call without a logged-in user — every value is derived from * site state, so the scheduled refresh can use it from cron. * * @return array */ public function get_group_properties(): array { $metrics = ( new Landing_Profile() )->get_metrics(); $properties = array( // Platform. 'php_version' => PHP_VERSION, 'wp_version' => get_bloginfo( 'version' ), 'wc_version' => $this->get_wc_version(), 'mysql_version' => $this->get_mysql_version(), 'wcpos_version' => PLUGIN_VERSION, 'wcpos_edition' => wcpos_is_pro_active() ? 'pro' : 'free', 'pro_license_active' => \WCPOS\WooCommercePOS\Templates::is_pro_license_active(), 'pro_fit_segment' => self::pro_fit_segment( wcpos_is_pro_active(), (int) ( $metrics['pos_user_count'] ?? 0 ), \count( (array) ( $metrics['active_gateways'] ?? array() ) ), self::band( (int) ( $metrics['order_count'] ?? 0 ) ) ), // Locale and market. 'wc_country' => $this->get_base_country(), 'wc_currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '', 'locale' => get_locale(), 'timezone' => wp_timezone_string(), // Environment shape. 'multisite' => is_multisite(), 'hpos_enabled' => $this->is_hpos_enabled(), 'tax_enabled' => function_exists( 'wc_tax_enabled' ) ? wc_tax_enabled() : false, 'multi_currency' => $this->has_multi_currency(), // Catalogue and trading volume — banded, never raw. These are the // numbers that would fingerprint a store. 'product_count_band' => self::band( (int) ( $metrics['product_count'] ?? 0 ) ), 'order_count_band' => self::band( (int) ( $metrics['order_count'] ?? 0 ) ), // Small-cardinality operational counts, reported exactly. Banding a // staff count into "1-10 / 11-100" would erase the only interesting // thing about it — the difference between a one-person shop and a // six-till store — and neither number identifies a store. 'days_since_install' => (int) ( $metrics['days_since_install'] ?? 0 ), 'pos_user_count' => (int) ( $metrics['pos_user_count'] ?? 0 ), 'gateway_count' => \count( (array) ( $metrics['active_gateways'] ?? array() ) ), ); /** * Filters the property set attached to the PostHog `site` group. * * Returned values are sent verbatim. Do not add identifying data — * see the class docblock for what this surface deliberately omits. * * @since 1.10.0 * * @param array $properties The group properties. */ return apply_filters( 'woocommerce_pos_analytics_group_properties', $properties ); } /** * Build the feature-adoption snapshot sent with `admin_landing_viewed`. * * One snapshot per landing view answers "what share of stores enable X" * without an event per toggle. Booleans and a fixed enum only — the same * allowlist discipline as the group properties, and for the same reason. * * `barcode_field` is deliberately reduced to default-or-custom: the raw * value is a meta key the merchant chose and can name anything, so it is * free-text from a store we have no business reading. * * @return array */ public function get_settings_summary(): array { // Read through the typed accessors rather than the raw option. They are // what the plugin itself acts on, so the snapshot reports the behaviour // the merchant actually gets. It matters: `force_ssl` can still hold the // legacy string "false" on upgraded stores, which is why its accessor — // alone among the booleans — normalizes with wp_validate_boolean(). A // raw (bool) cast on that value yields true and reports the opposite of // what the store does. $settings = Settings::instance(); $defaults = ( new Settings\General_Section() )->defaults(); $summary = array( 'pos_only_products' => $settings->pos_only_products_enabled(), 'decimal_qty' => $settings->decimal_qty_enabled(), 'force_ssl' => $settings->force_ssl_enabled(), 'generate_username' => $settings->generate_username_enabled(), 'default_customer_is_cashier' => $settings->default_customer_is_cashier(), 'restore_stock_on_delete' => $settings->restore_stock_on_delete_enabled(), 'storefront_receipt_enabled' => wp_validate_boolean( $settings->get_settings( 'general', 'storefront_receipt_enabled' ) ), 'barcode_field' => $defaults['barcode_field'] === $settings->barcode_field() ? 'default' : 'custom', // Report the persisted answer, so the property cannot describe a // consent state the merchant never chose. 'tracking_consent' => $settings->raw_tracking_consent(), 'enabled_gateway_count' => \count( (array) ( ( new Landing_Profile() )->get_metrics()['active_gateways'] ?? array() ) ), ); /** * Filters the feature-adoption snapshot sent with the landing view. * * @since 1.10.0 * * @param array $summary The settings summary. */ return apply_filters( 'woocommerce_pos_analytics_settings_summary', $summary ); } /** * Map a raw count onto its reporting band. * * @param int $count The raw count. * * @return string The band label. */ public static function band( int $count ): string { foreach ( self::COUNT_BANDS as $label => $upper_bound ) { if ( $count <= $upper_bound ) { return $label; } } return self::OVERFLOW_BAND; } /** * Classify a store by how closely it resembles the stores that buy Pro. * * One label per site, strongest signal wins. It exists so PostHog can * split free stores into a handful of named segments without anyone * re-deriving the cut-offs from raw properties in every insight. * * Thresholds come from a 2026-09-04 ClickHouse analysis of free-plugin sites * with a known domain (about 190 with profile data). Share of sites holding a * Pro license: 57-100% at pos_user_count >= 4 vs 11-18% below; 85-100% at * gateway_count >= 3 vs 24% at 2; 41-53% at order_count_band 101-1000 / * 1000+ vs 11-30% below. Baseline across all sites was about 9-17%. * * @param bool $is_pro Whether WCPOS Pro is active. * @param int $pos_user_count Number of POS users. * @param int $gateway_count Number of active gateways. * @param string $order_count_band Banded order count. * * @return string One of pro, multi_register, multi_gateway, high_volume, starter. */ public static function pro_fit_segment( bool $is_pro, int $pos_user_count, int $gateway_count, string $order_count_band ): string { if ( $is_pro ) { return 'pro'; } if ( $pos_user_count >= self::MULTI_REGISTER_MIN_POS_USERS ) { return 'multi_register'; } if ( $gateway_count >= self::MULTI_GATEWAY_MIN_GATEWAYS ) { return 'multi_gateway'; } return \in_array( $order_count_band, self::HIGH_VOLUME_ORDER_COUNT_BANDS, true ) ? 'high_volume' : 'starter'; } /** * Get the WooCommerce version, or an empty string when WC is unavailable. * * @return string */ private function get_wc_version(): string { if ( ! function_exists( 'WC' ) ) { return ''; } return (string) WC()->version; } /** * Get the database server version. * * @return string */ private function get_mysql_version(): string { global $wpdb; if ( ! isset( $wpdb ) || ! method_exists( $wpdb, 'db_version' ) ) { return ''; } return (string) $wpdb->db_version(); } /** * Get the WooCommerce base country. * * @return string */ private function get_base_country(): string { if ( ! function_exists( 'WC' ) ) { return ''; } return (string) WC()->countries->get_base_country(); } /** * Whether WooCommerce High-Performance Order Storage is in use. * * @return bool */ private function is_hpos_enabled(): bool { if ( ! class_exists( OrderUtil::class ) ) { return false; } return OrderUtil::custom_orders_table_usage_is_enabled(); } /** * The class names checked to detect a multi-currency plugin. * * Exists as a method rather than reading the constant inline so the list is * typed as plain strings. Read straight from the constant, static analysis * knows the exact literals, sees that none of these third-party classes * exist anywhere in this codebase, and reports the runtime check as * impossible — which it is not: it is answered on the merchant's site. * * @return string[] */ private function multi_currency_classes(): array { return self::MULTI_CURRENCY_CLASSES; } /** * Whether a known multi-currency plugin is active. * * @return bool */ private function has_multi_currency(): bool { $detected = false; foreach ( $this->multi_currency_classes() as $class_name ) { if ( class_exists( $class_name ) ) { $detected = true; break; } } /** * Filters whether the site is treated as running multi-currency. * * @since 1.10.0 * * @param bool $detected Whether a known multi-currency plugin was found. */ return (bool) apply_filters( 'woocommerce_pos_analytics_multi_currency', $detected ); } }