PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.25
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.25
1.10.25 1.10.24 1.10.23 1.10.22 1.10.21 1.10.20 1.10.19 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 All 169 releases
woocommerce-pos / includes / Services / Tax_Id_Detector.php

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

363 lines 11.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Tax ID Detector.
4 *
5 * Detects which third-party tax-ID plugin (if any) is active on the site and
6 * builds a per-type "write map" — for each Tax_Id_Types type, the meta key that
7 * WCPOS should write to. Order of precedence:
8 *
9 * 1. Detected active third-party plugin (recognised by basename + populated keys).
10 * 2. Populated-key scan over recent orders (when no plugin is recognised).
11 * 3. WCPOS sensible defaults (see Tax_Id_Settings::default_write_map()).
12 *
13 * The result is consumed by Tax_Id_Writer. Pure-logic helpers are exposed as
14 * statics so the heuristics are unit-testable without WordPress.
15 *
16 * @package WCPOS\WooCommercePOS
17 */
18
19 namespace WCPOS\WooCommercePOS\Services;
20
21 /**
22 * Tax_Id_Detector class.
23 */
24 class Tax_Id_Detector {
25 /**
26 * Per-request detection summary, built once per request by {@see summary()}.
27 *
28 * @var null|array{plugins:array<int,string>,write_map:array<string,string>}
29 */
30 private static $summary_cache = null;
31
32 /**
33 * Recognised plugin definitions. Each entry maps a "plugin id" used in the
34 * detection result to:
35 *
36 * - basename: the plugin file basename (matches `is_plugin_active()`).
37 * - alt_basenames: alternative folders/files seen in the wild.
38 * - keys: per-type meta keys that this plugin writes.
39 *
40 * @var array<string,array{basename:string,alt_basenames?:array<int,string>,keys:array<string,string>}>
41 */
42 const PLUGINS = array(
43 'wc_eu_vat_number' => array(
44 'basename' => 'woocommerce-eu-vat-number/woocommerce-eu-vat-number.php',
45 'alt_basenames' => array(),
46 'keys' => array(
47 Tax_Id_Types::TYPE_EU_VAT => '_billing_vat_number',
48 Tax_Id_Types::TYPE_GB_VAT => '_billing_vat_number',
49 ),
50 ),
51 'aelia_eu_vat' => array(
52 'basename' => 'aelia-eu-vat-assistant/aelia-eu-vat-assistant.php',
53 'alt_basenames' => array(),
54 'keys' => array(
55 Tax_Id_Types::TYPE_EU_VAT => '_eu_vat_data',
56 Tax_Id_Types::TYPE_GB_VAT => '_eu_vat_data',
57 ),
58 ),
59 'wpfactory_eu_vat' => array(
60 'basename' => 'eu-vat-for-woocommerce/eu-vat-for-woocommerce.php',
61 'alt_basenames' => array(
62 'wpfactory-eu-vat-number/wpfactory-eu-vat-number.php',
63 ),
64 'keys' => array(
65 Tax_Id_Types::TYPE_EU_VAT => '_billing_eu_vat_number',
66 Tax_Id_Types::TYPE_GB_VAT => '_billing_eu_vat_number',
67 ),
68 ),
69 'germanized' => array(
70 'basename' => 'woocommerce-germanized/woocommerce-germanized.php',
71 'alt_basenames' => array(
72 'woocommerce-germanized-pro/woocommerce-germanized-pro.php',
73 ),
74 'keys' => array(
75 Tax_Id_Types::TYPE_EU_VAT => '_billing_vat_id',
76 ),
77 ),
78 'br_market' => array(
79 'basename' => 'woocommerce-extra-checkout-fields-for-brazil/woocommerce-extra-checkout-fields-for-brazil.php',
80 'alt_basenames' => array(
81 'brazilian-market-on-woocommerce/brazilian-market-on-woocommerce.php',
82 ),
83 'keys' => array(
84 Tax_Id_Types::TYPE_BR_CPF => '_billing_cpf',
85 Tax_Id_Types::TYPE_BR_CNPJ => '_billing_cnpj',
86 ),
87 ),
88 'es_nif' => array(
89 'basename' => 'wc-apg-nif-cif-spain/wc-apg-nif-cif-spain.php',
90 'alt_basenames' => array(
91 'woocommerce-nif-cif-spain/woocommerce-nif-cif-spain.php',
92 ),
93 'keys' => array(
94 Tax_Id_Types::TYPE_ES_NIF => '_billing_nif',
95 ),
96 ),
97 );
98
99 /**
100 * Whether `is_plugin_active()` is callable in this request context.
101 * Loads `wp-admin/includes/plugin.php` lazily if necessary.
102 *
103 * @return bool
104 */
105 public static function ensure_plugin_helpers_loaded(): bool {
106 if ( \function_exists( 'is_plugin_active' ) ) {
107 return true;
108 }
109
110 $file = ABSPATH . 'wp-admin/includes/plugin.php';
111 if ( is_readable( $file ) ) {
112 require_once $file;
113 }
114
115 return \function_exists( 'is_plugin_active' );
116 }
117
118 /**
119 * Detect active recognised plugins.
120 *
121 * @return array<int,string> Plugin ids (keys of self::PLUGINS) that are active.
122 */
123 public static function active_plugin_ids(): array {
124 if ( ! self::ensure_plugin_helpers_loaded() ) {
125 return array();
126 }
127
128 $active = array();
129 foreach ( self::PLUGINS as $plugin_id => $def ) {
130 $candidates = array_merge( array( $def['basename'] ), $def['alt_basenames'] );
131 foreach ( $candidates as $basename ) {
132 if ( \is_plugin_active( $basename ) ) {
133 $active[] = $plugin_id;
134 break;
135 }
136 }
137 }
138
139 return $active;
140 }
141
142 /**
143 * Build the per-type write map by combining detection signals with defaults.
144 *
145 * Precedence (later entries overwrite earlier):
146 * 1. WCPOS defaults
147 * 2. Inferred from populated-key scan (if any)
148 * 3. Active plugin claims
149 * 4. User overrides (passed in)
150 *
151 * @param array<string,string> $defaults Default per-type → meta-key map.
152 * @param array<string,string> $inferred Per-type → meta-key map inferred from order scan.
153 * @param array<int,string> $active_plugins Plugin ids that are active.
154 * @param array<string,string> $overrides User-supplied per-type overrides.
155 *
156 * @return array<string,string>
157 */
158 public static function compose_write_map(
159 array $defaults,
160 array $inferred,
161 array $active_plugins,
162 array $overrides
163 ): array {
164 $map = $defaults;
165
166 foreach ( $inferred as $type => $key ) {
167 if ( Tax_Id_Types::is_valid_type( $type ) && \is_string( $key ) && '' !== $key ) {
168 $map[ $type ] = $key;
169 }
170 }
171
172 foreach ( $active_plugins as $plugin_id ) {
173 $plugin = self::PLUGINS[ $plugin_id ] ?? null;
174 if ( null === $plugin ) {
175 continue;
176 }
177 foreach ( $plugin['keys'] as $type => $key ) {
178 if ( ! Tax_Id_Types::is_valid_type( $type ) ) {
179 continue;
180 }
181 $map[ $type ] = $key;
182 }
183 }
184
185 foreach ( $overrides as $type => $key ) {
186 if ( Tax_Id_Types::is_valid_type( $type ) && \is_string( $key ) && '' !== $key ) {
187 $map[ $type ] = $key;
188 }
189 }
190
191 return $map;
192 }
193
194 /**
195 * Scan recent orders for populated tax-ID-like meta keys and return the
196 * per-type → meta-key map this implies.
197 *
198 * Heuristic: for each candidate key, count the number of populated rows in
199 * the last `$limit` orders. Pick the most-populated key per type.
200 *
201 * @param int $limit Max number of recent orders to inspect.
202 *
203 * @return array<string,string>
204 */
205 public static function infer_from_recent_orders( int $limit = 200 ): array {
206 if ( $limit <= 0 ) {
207 return array();
208 }
209
210 // Candidate key → type mapping for the scan. Reuses Tax_Id_Reader's fallback chain
211 // for direct types; generic VAT keys all map to TYPE_EU_VAT here (the inference is
212 // intentionally coarse — a per-row country prefix lookup is overkill for this).
213 $candidates = array(
214 '_billing_vat_number' => Tax_Id_Types::TYPE_EU_VAT,
215 '_billing_eu_vat_number' => Tax_Id_Types::TYPE_EU_VAT,
216 '_vat_number' => Tax_Id_Types::TYPE_EU_VAT,
217 '_billing_vat' => Tax_Id_Types::TYPE_EU_VAT,
218 '_billing_vat_id' => Tax_Id_Types::TYPE_EU_VAT,
219 '_billing_cpf' => Tax_Id_Types::TYPE_BR_CPF,
220 '_billing_cnpj' => Tax_Id_Types::TYPE_BR_CNPJ,
221 '_billing_gstin' => Tax_Id_Types::TYPE_IN_GST,
222 '_billing_cf' => Tax_Id_Types::TYPE_IT_CF,
223 '_billing_codice_fiscale' => Tax_Id_Types::TYPE_IT_CF,
224 '_billing_piva' => Tax_Id_Types::TYPE_IT_PIVA,
225 '_billing_partita_iva' => Tax_Id_Types::TYPE_IT_PIVA,
226 '_billing_nif' => Tax_Id_Types::TYPE_ES_NIF,
227 '_billing_cuit' => Tax_Id_Types::TYPE_AR_CUIT,
228 );
229
230 if ( ! \function_exists( 'wc_get_orders' ) ) {
231 return array();
232 }
233
234 /**
235 * Ids only, then one grouped count over the meta table. Hydrating the
236 * orders loaded every meta row of the newest 200 into memory on each POS
237 * order write and exhausted a 128 MB request on a legacy-storage store.
238 *
239 * @var array<int, int|string>|mixed $ids The stub over-narrows every wc_get_orders() result to WC_Order[].
240 */
241 $ids = \wc_get_orders(
242 array(
243 'limit' => $limit,
244 'orderby' => 'date',
245 'order' => 'DESC',
246 'status' => 'any',
247 'return' => 'ids',
248 )
249 );
250 $ids = array_values( array_filter( array_map( 'intval', \is_array( $ids ) ? $ids : array() ) ) );
251 if ( array() === $ids ) {
252 return array();
253 }
254
255 $counts = self::count_populated_keys( $ids, array_keys( $candidates ) );
256
257 // Pick the top-counted key per type.
258 $best = array();
259 foreach ( $candidates as $meta_key => $type ) {
260 if ( $counts[ $meta_key ] <= 0 ) {
261 continue;
262 }
263 if ( ! isset( $best[ $type ] ) || $counts[ $meta_key ] > $counts[ $best[ $type ] ] ) {
264 $best[ $type ] = $meta_key;
265 }
266 }
267
268 $inferred = array();
269 foreach ( $best as $type => $meta_key ) {
270 $inferred[ $type ] = $meta_key;
271 }
272
273 return $inferred;
274 }
275
276 /**
277 * How many of the given orders carry a non-empty value for each meta key.
278 *
279 * Reads the order meta table of the active datastore directly: under HPOS
280 * the rows live in `wc_orders_meta`, otherwise in `wp_postmeta`, the same
281 * split {@see Pos_Uuid::get_order_ids_by_uuid()} makes.
282 *
283 * @param int[] $ids Order ids to inspect.
284 * @param string[] $keys Candidate meta keys.
285 *
286 * @return array<string,int> Populated-order count per key, zero when absent.
287 */
288 private static function count_populated_keys( array $ids, array $keys ): array {
289 global $wpdb;
290
291 $counts = array_fill_keys( $keys, 0 );
292 if ( array() === $ids || array() === $keys ) {
293 return $counts;
294 }
295
296 $order_util = '\\Automattic\\WooCommerce\\Utilities\\OrderUtil';
297 $hpos = class_exists( $order_util )
298 && method_exists( $order_util, 'custom_orders_table_usage_is_enabled' )
299 && call_user_func( array( $order_util, 'custom_orders_table_usage_is_enabled' ) );
300 $table = $hpos ? $wpdb->prefix . 'wc_orders_meta' : $wpdb->postmeta;
301 $id_column = $hpos ? 'order_id' : 'post_id';
302
303 $id_placeholders = implode( ',', array_fill( 0, \count( $ids ), '%d' ) );
304 $key_placeholders = implode( ',', array_fill( 0, \count( $keys ), '%s' ) );
305
306 // "Populated" means what the order getter used to decode as non-empty: a
307 // plugin that initialises a key with an empty array, an empty string or
308 // null stores `a:0:{}`, `s:0:"";` or `N;`, and those must not count.
309 $empty_values = array( '', 'a:0:{}', 's:0:"";', 'N;' );
310 $empty_placeholders = implode( ',', array_fill( 0, \count( $empty_values ), '%s' ) );
311
312 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table and column names are fixed above; every value goes through a placeholder.
313 $sql = "SELECT meta_key, COUNT(DISTINCT {$id_column}) AS populated FROM {$table}"
314 . " WHERE {$id_column} IN ({$id_placeholders}) AND meta_key IN ({$key_placeholders})"
315 . " AND meta_value NOT IN ({$empty_placeholders})"
316 . ' GROUP BY meta_key';
317 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
318 $rows = $wpdb->get_results( $wpdb->prepare( $sql, array_merge( $ids, $keys, $empty_values ) ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepared here with the placeholders built above.
319
320 foreach ( (array) $rows as $row ) {
321 if ( isset( $counts[ $row['meta_key'] ] ) ) {
322 $counts[ $row['meta_key'] ] = (int) $row['populated'];
323 }
324 }
325
326 return $counts;
327 }
328
329 /**
330 * Build the full detection summary for a request. Cached per-request.
331 *
332 * @return array{plugins:array<int,string>,write_map:array<string,string>}
333 */
334 public function summary(): array {
335 if ( null !== self::$summary_cache ) {
336 return self::$summary_cache;
337 }
338
339 $active = self::active_plugin_ids();
340 $inferred = empty( $active ) ? self::infer_from_recent_orders() : array();
341 $overrides = Tax_Id_Settings::get_overrides();
342 $defaults = Tax_Id_Settings::default_write_map();
343
344 self::$summary_cache = array(
345 'plugins' => $active,
346 'write_map' => self::compose_write_map( $defaults, $inferred, $active, $overrides ),
347 );
348
349 return self::$summary_cache;
350 }
351
352 /**
353 * Discard the per-request summary cache. Tests only: the PHPUnit process
354 * never ends between cases, so a warm cache would hide whether a write
355 * path asks the detector at all.
356 *
357 * @internal
358 */
359 public static function reset_request_state(): void {
360 self::$summary_cache = null;
361 }
362 }
363