PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.17
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.17
1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 All 162 releases
woocommerce-pos / includes / Services / Analytics_Profile.php

Analytics_Profile.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.17, at includes/Services/Analytics_Profile.php

338 lines 11.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Analytics site profile.
4 *
5 * Builds the property set attached to the PostHog `site` group. This is the
6 * ONLY place that decides what environment data leaves the install, and it is
7 * deliberately an allowlist: every property is named and shaped here, so no
8 * field can reach PostHog by being added to some other service's payload.
9 *
10 * Two rules the shape follows, both from the telemetry spec (#793):
11 *
12 * - Counts are reported as BANDS, never raw. "this store has 47 products" is
13 * a store metric; "this store is in the 11-100 band" answers every product
14 * question we actually ask, without carrying a fingerprint.
15 * - Payment gateways are reported as a COUNT, never as names. Which gateways a
16 * merchant runs is their business.
17 *
18 * Never add the site URL, admin URL, admin email, IP address, or any
19 * per-order value here. Landing_Profile intentionally carries `site_domain`
20 * and `admin_domain` for the updates server; those must not be copied in.
21 *
22 * @package WCPOS\WooCommercePOS\Services
23 */
24
25 namespace WCPOS\WooCommercePOS\Services;
26
27 use Automattic\WooCommerce\Utilities\OrderUtil;
28 use const WCPOS\WooCommercePOS\VERSION as PLUGIN_VERSION;
29
30 /**
31 * Analytics_Profile service class.
32 */
33 class Analytics_Profile {
34 /**
35 * Upper bound of each count band, in ascending order, mapped to its label.
36 *
37 * A count is reported with the label of the first band it fits under.
38 * Anything above the largest bound falls through to OVERFLOW_BAND.
39 */
40 const COUNT_BANDS = array(
41 '0' => 0,
42 '1-10' => 10,
43 '11-100' => 100,
44 '101-1000' => 1000,
45 );
46
47 /**
48 * Label used for counts above the largest band.
49 *
50 * @var string
51 */
52 const OVERFLOW_BAND = '1000+';
53
54 /**
55 * Pro-fit thresholds — see pro_fit_segment() for where the numbers come from.
56 */
57 const MULTI_REGISTER_MIN_POS_USERS = 4;
58 const MULTI_GATEWAY_MIN_GATEWAYS = 3;
59 const HIGH_VOLUME_ORDER_COUNT_BANDS = array( '101-1000', '1000+' );
60
61 /**
62 * Classes that indicate a multi-currency plugin is running.
63 *
64 * WooCommerce core has no multi-currency concept, so presence has to be
65 * inferred from the well-known implementations. This is a best-effort
66 * signal for segmentation, not a contract — extend it via the
67 * `woocommerce_pos_analytics_multi_currency` filter rather than assuming
68 * the list is complete.
69 *
70 * @var string[]
71 */
72 const MULTI_CURRENCY_CLASSES = array(
73 'WCML_Multi_Currency', // WPML WooCommerce Multilingual.
74 'WC_Aelia_CurrencySwitcher', // Aelia Currency Switcher.
75 'WOOMULTI_CURRENCY_F', // CURCY / WooCommerce Multi Currency.
76 'Alg_WC_Currency_Switcher', // Currency Switcher for WooCommerce.
77 );
78
79 /**
80 * Build the `site` group properties.
81 *
82 * Safe to call without a logged-in user — every value is derived from
83 * site state, so the scheduled refresh can use it from cron.
84 *
85 * @return array<string, mixed>
86 */
87 public function get_group_properties(): array {
88 $metrics = ( new Landing_Profile() )->get_metrics();
89
90 $properties = array(
91 // Platform.
92 'php_version' => PHP_VERSION,
93 'wp_version' => get_bloginfo( 'version' ),
94 'wc_version' => $this->get_wc_version(),
95 'mysql_version' => $this->get_mysql_version(),
96 'wcpos_version' => PLUGIN_VERSION,
97 'wcpos_edition' => wcpos_is_pro_active() ? 'pro' : 'free',
98 'pro_license_active' => \WCPOS\WooCommercePOS\Templates::is_pro_license_active(),
99 'pro_fit_segment' => self::pro_fit_segment(
100 wcpos_is_pro_active(),
101 (int) ( $metrics['pos_user_count'] ?? 0 ),
102 \count( (array) ( $metrics['active_gateways'] ?? array() ) ),
103 self::band( (int) ( $metrics['order_count'] ?? 0 ) )
104 ),
105
106 // Locale and market.
107 'wc_country' => $this->get_base_country(),
108 'wc_currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '',
109 'locale' => get_locale(),
110 'timezone' => wp_timezone_string(),
111
112 // Environment shape.
113 'multisite' => is_multisite(),
114 'hpos_enabled' => $this->is_hpos_enabled(),
115 'tax_enabled' => function_exists( 'wc_tax_enabled' ) ? wc_tax_enabled() : false,
116 'multi_currency' => $this->has_multi_currency(),
117
118 // Catalogue and trading volume — banded, never raw. These are the
119 // numbers that would fingerprint a store.
120 'product_count_band' => self::band( (int) ( $metrics['product_count'] ?? 0 ) ),
121 'order_count_band' => self::band( (int) ( $metrics['order_count'] ?? 0 ) ),
122
123 // Small-cardinality operational counts, reported exactly. Banding a
124 // staff count into "1-10 / 11-100" would erase the only interesting
125 // thing about it — the difference between a one-person shop and a
126 // six-till store — and neither number identifies a store.
127 'days_since_install' => (int) ( $metrics['days_since_install'] ?? 0 ),
128 'pos_user_count' => (int) ( $metrics['pos_user_count'] ?? 0 ),
129 'gateway_count' => \count( (array) ( $metrics['active_gateways'] ?? array() ) ),
130 );
131
132 /**
133 * Filters the property set attached to the PostHog `site` group.
134 *
135 * Returned values are sent verbatim. Do not add identifying data —
136 * see the class docblock for what this surface deliberately omits.
137 *
138 * @since 1.10.0
139 *
140 * @param array<string, mixed> $properties The group properties.
141 */
142 return apply_filters( 'woocommerce_pos_analytics_group_properties', $properties );
143 }
144
145 /**
146 * Build the feature-adoption snapshot sent with `admin_landing_viewed`.
147 *
148 * One snapshot per landing view answers "what share of stores enable X"
149 * without an event per toggle. Booleans and a fixed enum only — the same
150 * allowlist discipline as the group properties, and for the same reason.
151 *
152 * `barcode_field` is deliberately reduced to default-or-custom: the raw
153 * value is a meta key the merchant chose and can name anything, so it is
154 * free-text from a store we have no business reading.
155 *
156 * @return array<string, mixed>
157 */
158 public function get_settings_summary(): array {
159 // Read through the typed accessors rather than the raw option. They are
160 // what the plugin itself acts on, so the snapshot reports the behaviour
161 // the merchant actually gets. It matters: `force_ssl` can still hold the
162 // legacy string "false" on upgraded stores, which is why its accessor —
163 // alone among the booleans — normalizes with wp_validate_boolean(). A
164 // raw (bool) cast on that value yields true and reports the opposite of
165 // what the store does.
166 $settings = Settings::instance();
167 $defaults = ( new Settings\General_Section() )->defaults();
168
169 $summary = array(
170 'pos_only_products' => $settings->pos_only_products_enabled(),
171 'decimal_qty' => $settings->decimal_qty_enabled(),
172 'force_ssl' => $settings->force_ssl_enabled(),
173 'generate_username' => $settings->generate_username_enabled(),
174 'default_customer_is_cashier' => $settings->default_customer_is_cashier(),
175 'restore_stock_on_delete' => $settings->restore_stock_on_delete_enabled(),
176 'storefront_receipt_enabled' => wp_validate_boolean( $settings->get_settings( 'general', 'storefront_receipt_enabled' ) ),
177 'barcode_field' => $defaults['barcode_field'] === $settings->barcode_field() ? 'default' : 'custom',
178 // Report the persisted answer, so the property cannot describe a
179 // consent state the merchant never chose.
180 'tracking_consent' => $settings->raw_tracking_consent(),
181 'enabled_gateway_count' => \count( (array) ( ( new Landing_Profile() )->get_metrics()['active_gateways'] ?? array() ) ),
182 );
183
184 /**
185 * Filters the feature-adoption snapshot sent with the landing view.
186 *
187 * @since 1.10.0
188 *
189 * @param array<string, mixed> $summary The settings summary.
190 */
191 return apply_filters( 'woocommerce_pos_analytics_settings_summary', $summary );
192 }
193
194 /**
195 * Map a raw count onto its reporting band.
196 *
197 * @param int $count The raw count.
198 *
199 * @return string The band label.
200 */
201 public static function band( int $count ): string {
202 foreach ( self::COUNT_BANDS as $label => $upper_bound ) {
203 if ( $count <= $upper_bound ) {
204 return $label;
205 }
206 }
207
208 return self::OVERFLOW_BAND;
209 }
210
211 /**
212 * Classify a store by how closely it resembles the stores that buy Pro.
213 *
214 * One label per site, strongest signal wins. It exists so PostHog can
215 * split free stores into a handful of named segments without anyone
216 * re-deriving the cut-offs from raw properties in every insight.
217 *
218 * Thresholds come from a 2026-09-04 ClickHouse analysis of free-plugin sites
219 * with a known domain (about 190 with profile data). Share of sites holding a
220 * Pro license: 57-100% at pos_user_count >= 4 vs 11-18% below; 85-100% at
221 * gateway_count >= 3 vs 24% at 2; 41-53% at order_count_band 101-1000 /
222 * 1000+ vs 11-30% below. Baseline across all sites was about 9-17%.
223 *
224 * @param bool $is_pro Whether WCPOS Pro is active.
225 * @param int $pos_user_count Number of POS users.
226 * @param int $gateway_count Number of active gateways.
227 * @param string $order_count_band Banded order count.
228 *
229 * @return string One of pro, multi_register, multi_gateway, high_volume, starter.
230 */
231 public static function pro_fit_segment( bool $is_pro, int $pos_user_count, int $gateway_count, string $order_count_band ): string {
232 if ( $is_pro ) {
233 return 'pro';
234 }
235 if ( $pos_user_count >= self::MULTI_REGISTER_MIN_POS_USERS ) {
236 return 'multi_register';
237 }
238 if ( $gateway_count >= self::MULTI_GATEWAY_MIN_GATEWAYS ) {
239 return 'multi_gateway';
240 }
241 return \in_array( $order_count_band, self::HIGH_VOLUME_ORDER_COUNT_BANDS, true ) ? 'high_volume' : 'starter';
242 }
243
244 /**
245 * Get the WooCommerce version, or an empty string when WC is unavailable.
246 *
247 * @return string
248 */
249 private function get_wc_version(): string {
250 if ( ! function_exists( 'WC' ) ) {
251 return '';
252 }
253
254 return (string) WC()->version;
255 }
256
257 /**
258 * Get the database server version.
259 *
260 * @return string
261 */
262 private function get_mysql_version(): string {
263 global $wpdb;
264
265 if ( ! isset( $wpdb ) || ! method_exists( $wpdb, 'db_version' ) ) {
266 return '';
267 }
268
269 return (string) $wpdb->db_version();
270 }
271
272 /**
273 * Get the WooCommerce base country.
274 *
275 * @return string
276 */
277 private function get_base_country(): string {
278 if ( ! function_exists( 'WC' ) ) {
279 return '';
280 }
281
282 return (string) WC()->countries->get_base_country();
283 }
284
285 /**
286 * Whether WooCommerce High-Performance Order Storage is in use.
287 *
288 * @return bool
289 */
290 private function is_hpos_enabled(): bool {
291 if ( ! class_exists( OrderUtil::class ) ) {
292 return false;
293 }
294
295 return OrderUtil::custom_orders_table_usage_is_enabled();
296 }
297
298 /**
299 * The class names checked to detect a multi-currency plugin.
300 *
301 * Exists as a method rather than reading the constant inline so the list is
302 * typed as plain strings. Read straight from the constant, static analysis
303 * knows the exact literals, sees that none of these third-party classes
304 * exist anywhere in this codebase, and reports the runtime check as
305 * impossible — which it is not: it is answered on the merchant's site.
306 *
307 * @return string[]
308 */
309 private function multi_currency_classes(): array {
310 return self::MULTI_CURRENCY_CLASSES;
311 }
312
313 /**
314 * Whether a known multi-currency plugin is active.
315 *
316 * @return bool
317 */
318 private function has_multi_currency(): bool {
319 $detected = false;
320
321 foreach ( $this->multi_currency_classes() as $class_name ) {
322 if ( class_exists( $class_name ) ) {
323 $detected = true;
324 break;
325 }
326 }
327
328 /**
329 * Filters whether the site is treated as running multi-currency.
330 *
331 * @since 1.10.0
332 *
333 * @param bool $detected Whether a known multi-currency plugin was found.
334 */
335 return (bool) apply_filters( 'woocommerce_pos_analytics_multi_currency', $detected );
336 }
337 }
338