PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.16
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 / Tax_Id_Reader.php

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

459 lines 13.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Tax ID Reader.
4 *
5 * Reads customer tax IDs from a WooCommerce order or user, falling back across
6 * the meta-key inventory used by the major third-party tax-ID plugins
7 * (WooCommerce EU VAT Number, Aelia EU VAT Assistant, Brazilian Market on
8 * WooCommerce, NIF/CIF Spain, Germanized, Italian add-ons, etc.) and
9 * normalising results into the canonical Tax ID shape:
10 *
11 * array(
12 * 'type' => 'eu_vat'|'gb_vat'|'au_abn'|'br_cpf'|'br_cnpj'|...,
13 * 'value' => string,
14 * 'country' => string|null, // ISO 3166-1 alpha-2
15 * 'label' => string|null,
16 * 'verified' => array|null, // { status, verified_at, verified_name, source }
17 * )
18 *
19 * Pure-logic parsing is exposed via `parse_meta_map()` for unit testability.
20 *
21 * @package WCPOS\WooCommercePOS
22 */
23
24 namespace WCPOS\WooCommercePOS\Services;
25
26 use WC_Abstract_Order;
27
28 /**
29 * Tax_Id_Reader class.
30 */
31 class Tax_Id_Reader {
32 /**
33 * Canonical meta key for WCPOS-written tax IDs (JSON-encoded TaxId[]).
34 *
35 * @var string
36 */
37 const CANONICAL_META_KEY = '_wcpos_tax_ids';
38
39 /**
40 * Sentinel handler used for generic VAT meta (EU vs GB inferred from country).
41 *
42 * @var string
43 */
44 const HANDLER_GENERIC_VAT = '__generic_vat__';
45
46 /**
47 * Sentinel handler used for the Aelia EU VAT Assistant structured array.
48 *
49 * @var string
50 */
51 const HANDLER_AELIA_EU_VAT = '__aelia_eu_vat__';
52
53 /**
54 * Order-meta fallback chain. Keys are real meta keys; values are either a
55 * tax-ID type constant (from Tax_Id_Types) or one of the HANDLER_* sentinels.
56 *
57 * Iteration order is the read priority; ties are broken by dedupe-on-first-seen.
58 *
59 * @var array<string,string>
60 */
61 const ORDER_FALLBACK_KEYS = array(
62 '_eu_vat_data' => self::HANDLER_AELIA_EU_VAT,
63 '_vat_number' => self::HANDLER_GENERIC_VAT,
64 '_billing_vat_number' => self::HANDLER_GENERIC_VAT,
65 '_billing_eu_vat_number' => self::HANDLER_GENERIC_VAT,
66 '_billing_vat' => self::HANDLER_GENERIC_VAT,
67 '_vat_id' => self::HANDLER_GENERIC_VAT,
68 '_billing_vat_id' => self::HANDLER_GENERIC_VAT,
69 '_billing_cpf' => Tax_Id_Types::TYPE_BR_CPF,
70 '_billing_cnpj' => Tax_Id_Types::TYPE_BR_CNPJ,
71 '_billing_gstin' => Tax_Id_Types::TYPE_IN_GST,
72 '_billing_cf' => Tax_Id_Types::TYPE_IT_CF,
73 '_billing_codice_fiscale' => Tax_Id_Types::TYPE_IT_CF,
74 '_billing_piva' => Tax_Id_Types::TYPE_IT_PIVA,
75 '_billing_partita_iva' => Tax_Id_Types::TYPE_IT_PIVA,
76 '_billing_nif' => Tax_Id_Types::TYPE_ES_NIF,
77 '_billing_dni' => Tax_Id_Types::TYPE_AR_CUIT,
78 '_billing_cuit' => Tax_Id_Types::TYPE_AR_CUIT,
79 '_billing_tax_number' => Tax_Id_Types::TYPE_OTHER,
80 );
81
82 /**
83 * User-meta variants of the order fallback keys.
84 *
85 * @return array<int,string>
86 */
87 public static function fallback_user_meta_keys(): array {
88 $keys = array();
89 foreach ( array_keys( self::ORDER_FALLBACK_KEYS ) as $meta_key ) {
90 $keys[] = ltrim( $meta_key, '_' );
91 }
92
93 return array_values( array_unique( $keys ) );
94 }
95
96 /**
97 * Read tax IDs for a WooCommerce order. Reads only from the order's own meta
98 * (the order is treated as the snapshot — it is never re-pulled from the
99 * customer record).
100 *
101 * @param WC_Abstract_Order $order WooCommerce order.
102 *
103 * @return array<int,array<string,mixed>>
104 */
105 public function read_for_order( WC_Abstract_Order $order ): array {
106 $billing_country = strtoupper( (string) $order->get_billing_country() );
107
108 $meta_map = array();
109 $meta_map[ self::CANONICAL_META_KEY ] = $order->get_meta( self::CANONICAL_META_KEY, true );
110 $has_canonical = null !== self::parse_canonical( $meta_map[ self::CANONICAL_META_KEY ] );
111 $owned_keys = (array) $order->get_meta( Tax_Id_Writer::OWNED_KEYS_META_KEY, true );
112
113 foreach ( array_keys( self::ORDER_FALLBACK_KEYS ) as $meta_key ) {
114 if ( $has_canonical && \in_array( $meta_key, $owned_keys, true ) ) {
115 continue;
116 }
117 $meta_map[ $meta_key ] = $order->get_meta( $meta_key, true );
118 }
119
120 return self::parse_meta_map( $meta_map, $billing_country );
121 }
122
123 /**
124 * Read tax IDs for a WP user (customer record). User meta uses the same key
125 * set as orders but without the leading underscore (WooCommerce convention),
126 * so we look up both variants for resilience.
127 *
128 * @param int $user_id User ID.
129 * @param null|string $billing_country Optional pre-fetched billing country (ISO alpha-2).
130 *
131 * @return array<int,array<string,mixed>>
132 */
133 public function read_for_user( int $user_id, $billing_country = null ): array {
134 if ( $user_id <= 0 ) {
135 return array();
136 }
137
138 if ( null === $billing_country ) {
139 $billing_country = (string) get_user_meta( $user_id, 'billing_country', true );
140 }
141 $billing_country = strtoupper( (string) $billing_country );
142
143 $meta_map = array();
144
145 // Canonical: stored on the user with the underscore-prefixed key.
146 $meta_map[ self::CANONICAL_META_KEY ] = get_user_meta( $user_id, self::CANONICAL_META_KEY, true );
147 $has_canonical = null !== self::parse_canonical( $meta_map[ self::CANONICAL_META_KEY ] );
148 $owned_keys = (array) get_user_meta( $user_id, Tax_Id_Writer::OWNED_KEYS_META_KEY, true );
149
150 foreach ( array_keys( self::ORDER_FALLBACK_KEYS ) as $meta_key ) {
151 if ( $has_canonical && \in_array( $meta_key, $owned_keys, true ) ) {
152 continue;
153 }
154
155 // User-meta variant: strip the leading underscore (WC convention).
156 $user_key = ltrim( $meta_key, '_' );
157 $meta_map[ $meta_key ] = get_user_meta( $user_id, $user_key, true );
158
159 // Some plugins also store underscore-prefixed user meta; honour it as fallback.
160 if ( '' === $meta_map[ $meta_key ] ) {
161 $meta_map[ $meta_key ] = get_user_meta( $user_id, $meta_key, true );
162 }
163 }
164
165 return self::parse_meta_map( $meta_map, $billing_country );
166 }
167
168 /**
169 * Pure-logic parser. Takes a fully-collected meta map and emits a deduped
170 * array of canonical TaxId entries.
171 *
172 * @param array<string,mixed> $meta_map Map of meta_key => raw meta value.
173 * @param null|string $billing_country ISO alpha-2 country code or null.
174 *
175 * @return array<int,array<string,mixed>>
176 */
177 public static function parse_meta_map( array $meta_map, $billing_country = null ): array {
178 $billing_country = null === $billing_country ? null : strtoupper( (string) $billing_country );
179 $results = array();
180
181 // 1. Canonical seed (highest priority).
182 if ( ! empty( $meta_map[ self::CANONICAL_META_KEY ] ) ) {
183 $canonical = self::parse_canonical( $meta_map[ self::CANONICAL_META_KEY ] );
184 if ( null !== $canonical ) {
185 foreach ( $canonical as $tax_id ) {
186 $results[] = $tax_id;
187 }
188 }
189 }
190
191 // 2. Fallback keys, in declared order.
192 foreach ( self::ORDER_FALLBACK_KEYS as $meta_key => $handler ) {
193 if ( ! isset( $meta_map[ $meta_key ] ) ) {
194 continue;
195 }
196 $raw = $meta_map[ $meta_key ];
197 if ( '' === $raw || array() === $raw ) {
198 continue;
199 }
200
201 if ( self::HANDLER_AELIA_EU_VAT === $handler ) {
202 $parsed = self::parse_aelia( $raw );
203 } elseif ( self::HANDLER_GENERIC_VAT === $handler ) {
204 $parsed = self::parse_generic_vat( (string) $raw, $billing_country );
205 } else {
206 $parsed = self::build_tax_id( $handler, (string) $raw );
207 }
208
209 if ( null !== $parsed ) {
210 $results[] = $parsed;
211 }
212 }
213
214 return self::dedupe( $results );
215 }
216
217 /**
218 * Parse the canonical `_wcpos_tax_ids` value, which may be a JSON-encoded
219 * string (preferred) or an already-decoded native PHP array.
220 *
221 * @param mixed $raw Meta value.
222 *
223 * @return null|array<int,array<string,mixed>>
224 */
225 private static function parse_canonical( $raw ) {
226 if ( \is_string( $raw ) ) {
227 $decoded = json_decode( $raw, true );
228 if ( ! \is_array( $decoded ) ) {
229 return null;
230 }
231 $raw = $decoded;
232 }
233
234 if ( ! \is_array( $raw ) ) {
235 return null;
236 }
237
238 $out = array();
239 foreach ( $raw as $entry ) {
240 if ( ! \is_array( $entry ) ) {
241 continue;
242 }
243 $type = isset( $entry['type'] ) ? (string) $entry['type'] : '';
244 $value = isset( $entry['value'] ) ? (string) $entry['value'] : '';
245 if ( '' === $value ) {
246 continue;
247 }
248 if ( ! Tax_Id_Types::is_valid_type( $type ) ) {
249 $type = Tax_Id_Types::TYPE_OTHER;
250 }
251
252 $tax_id = self::build_tax_id( $type, $value );
253 if ( null === $tax_id ) {
254 continue;
255 }
256
257 // Preserve optional fields from the canonical record.
258 if ( isset( $entry['country'] ) && '' !== $entry['country'] ) {
259 $tax_id['country'] = strtoupper( (string) $entry['country'] );
260 }
261 if ( isset( $entry['label'] ) && '' !== $entry['label'] ) {
262 $tax_id['label'] = (string) $entry['label'];
263 }
264 if ( isset( $entry['verified'] ) && \is_array( $entry['verified'] ) ) {
265 $tax_id['verified'] = $entry['verified'];
266 }
267
268 $out[] = $tax_id;
269 }
270
271 return $out;
272 }
273
274 /**
275 * Parse Aelia EU VAT Assistant's `_eu_vat_data` structure.
276 *
277 * Expected shape (PHP array, post-unserialize):
278 * array(
279 * 'vat_number' => 'IT12345678901',
280 * 'country' => 'IT',
281 * 'is_valid' => true|false,
282 * 'company_name' => string|null,
283 * 'request_date' => string|null,
284 * )
285 *
286 * @param mixed $raw Meta value.
287 *
288 * @return null|array<string,mixed>
289 */
290 private static function parse_aelia( $raw ) {
291 // Tolerate a serialized string (rare — WP usually unserializes for us).
292 if ( \is_string( $raw ) ) {
293 $maybe = @unserialize( $raw, array( 'allowed_classes' => false ) );
294 if ( false !== $maybe || 'b:0;' === $raw ) {
295 $raw = $maybe;
296 }
297 }
298 if ( ! \is_array( $raw ) ) {
299 return null;
300 }
301
302 $value = isset( $raw['vat_number'] ) ? (string) $raw['vat_number'] : '';
303 if ( '' === $value ) {
304 return null;
305 }
306
307 $country = isset( $raw['country'] ) ? strtoupper( (string) $raw['country'] ) : null;
308 $type = ( null !== $country && 'GB' === $country ) ? Tax_Id_Types::TYPE_GB_VAT : Tax_Id_Types::TYPE_EU_VAT;
309
310 $tax_id = self::build_tax_id( $type, $value );
311 if ( null === $tax_id ) {
312 return null;
313 }
314 if ( null !== $country ) {
315 $tax_id['country'] = $country;
316 }
317
318 // Map Aelia's is_valid into our verified shape.
319 if ( isset( $raw['is_valid'] ) ) {
320 $status = $raw['is_valid'] ? 'verified' : 'unverified';
321 $verified = array(
322 'status' => $status,
323 'source' => 'aelia',
324 );
325 if ( ! empty( $raw['company_name'] ) ) {
326 $verified['verified_name'] = (string) $raw['company_name'];
327 }
328 if ( ! empty( $raw['request_date'] ) ) {
329 $verified['verified_at'] = (string) $raw['request_date'];
330 }
331 $tax_id['verified'] = $verified;
332 }
333
334 return $tax_id;
335 }
336
337 /**
338 * Parse a generic VAT meta value. Disambiguates EU vs GB based on:
339 *
340 * 1. A two-letter country prefix in the value itself (e.g. "DE123456789").
341 * 2. The order's billing country.
342 * 3. Falls back to `eu_vat` with no country if both are absent.
343 *
344 * @param string $raw_value Raw meta value.
345 * @param null|string $billing_country ISO alpha-2 country code.
346 *
347 * @return null|array<string,mixed>
348 */
349 private static function parse_generic_vat( string $raw_value, $billing_country ) {
350 $value = self::normalize_value( $raw_value );
351 if ( '' === $value ) {
352 return null;
353 }
354
355 $country = null;
356 $type = Tax_Id_Types::TYPE_EU_VAT;
357
358 // Detect a two-letter country prefix at the start of the value.
359 if ( preg_match( '/^([A-Z]{2})([0-9A-Z].*)$/', $value, $matches ) ) {
360 $prefix = $matches[1];
361 if ( 'GB' === $prefix ) {
362 $country = 'GB';
363 $type = Tax_Id_Types::TYPE_GB_VAT;
364 } elseif ( Tax_Id_Types::is_eu_vat_country( $prefix ) ) {
365 $country = $prefix;
366 $type = Tax_Id_Types::TYPE_EU_VAT;
367 }
368 }
369
370 // No prefix — fall back to billing country.
371 if ( null === $country && null !== $billing_country && '' !== $billing_country ) {
372 if ( 'GB' === $billing_country ) {
373 $country = 'GB';
374 $type = Tax_Id_Types::TYPE_GB_VAT;
375 } elseif ( Tax_Id_Types::is_eu_vat_country( $billing_country ) ) {
376 $country = $billing_country;
377 $type = Tax_Id_Types::TYPE_EU_VAT;
378 }
379 }
380
381 $tax_id = self::build_tax_id( $type, $value );
382 if ( null === $tax_id ) {
383 return null;
384 }
385 if ( null !== $country ) {
386 $tax_id['country'] = $country;
387 }
388
389 return $tax_id;
390 }
391
392 /**
393 * Build a canonical TaxId entry from a (type, value) pair, applying default
394 * country derivation. Returns null for empty values.
395 *
396 * @param string $type Type constant.
397 * @param string $value Raw value.
398 *
399 * @return null|array<string,mixed>
400 */
401 private static function build_tax_id( string $type, string $value ) {
402 $normalized = self::normalize_value( $value );
403 if ( '' === $normalized ) {
404 return null;
405 }
406
407 $tax_id = array(
408 'type' => $type,
409 'value' => $normalized,
410 'country' => Tax_Id_Types::country_for_type( $type ),
411 'label' => null,
412 'verified' => null,
413 );
414
415 return $tax_id;
416 }
417
418 /**
419 * Normalise a tax-ID value: trim, collapse whitespace, uppercase.
420 *
421 * @param string $value Raw value.
422 *
423 * @return string
424 */
425 private static function normalize_value( string $value ): string {
426 $value = trim( $value );
427 if ( '' === $value ) {
428 return '';
429 }
430 // Collapse internal whitespace.
431 $value = (string) preg_replace( '/\s+/', '', $value );
432
433 return strtoupper( $value );
434 }
435
436 /**
437 * Dedupe an array of TaxId entries by (type, value), keeping the first
438 * occurrence (which is the highest-priority source).
439 *
440 * @param array<int,array<string,mixed>> $tax_ids Tax ID list.
441 *
442 * @return array<int,array<string,mixed>>
443 */
444 private static function dedupe( array $tax_ids ): array {
445 $seen = array();
446 $out = array();
447 foreach ( $tax_ids as $tax_id ) {
448 $key = $tax_id['type'] . '|' . $tax_id['value'];
449 if ( isset( $seen[ $key ] ) ) {
450 continue;
451 }
452 $seen[ $key ] = true;
453 $out[] = $tax_id;
454 }
455
456 return $out;
457 }
458 }
459