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_Writer.php

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

419 lines 12.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 Writer.
4 *
5 * Persists a normalised TaxId[] list onto a WooCommerce order or user. Each
6 * entry is dispatched to the meta key resolved by Tax_Id_Detector's write map
7 * (settings overrides → active plugin → populated-key inference → defaults).
8 *
9 * Writer behaviour:
10 * - Per-type dispatch by write_map.
11 * - Aelia EU VAT Assistant detection: when the resolved key is `_eu_vat_data`,
12 * write the structured array form rather than a flat string.
13 * - Ownership tracking: meta keys WCPOS wrote on this object are recorded in
14 * `_wcpos_tax_ids_owned_keys`. Used to safely strip on uninstall without
15 * touching keys owned by other plugins.
16 * - Verification metadata: when present, mirrored into the
17 * `_wcpos_tax_ids_verified` sidecar.
18 *
19 * Pure-logic helpers are exposed as statics so dispatch behaviour is unit
20 * testable without hitting the database.
21 *
22 * @package WCPOS\WooCommercePOS
23 */
24
25 namespace WCPOS\WooCommercePOS\Services;
26
27 use WC_Abstract_Order;
28
29 /**
30 * Tax_Id_Writer class.
31 */
32 class Tax_Id_Writer {
33 /**
34 * Sidecar meta key tracking which meta keys WCPOS wrote on this object.
35 *
36 * @var string
37 */
38 const OWNED_KEYS_META_KEY = '_wcpos_tax_ids_owned_keys';
39
40 /**
41 * Sidecar meta key for verification metadata.
42 *
43 * @var string
44 */
45 const VERIFIED_META_KEY = '_wcpos_tax_ids_verified';
46
47 /**
48 * Normalise a raw TaxId[] payload into the canonical shape, dropping invalid
49 * entries.
50 *
51 * @param array<int,mixed> $tax_ids Raw input.
52 *
53 * @return array<int,array<string,mixed>>
54 */
55 public static function normalize_input( array $tax_ids ): array {
56 $out = array();
57 foreach ( $tax_ids as $entry ) {
58 if ( ! \is_array( $entry ) ) {
59 continue;
60 }
61
62 $type = isset( $entry['type'] ) ? (string) $entry['type'] : '';
63 $value = isset( $entry['value'] ) ? (string) $entry['value'] : '';
64
65 if ( '' === $value ) {
66 continue;
67 }
68 if ( ! Tax_Id_Types::is_valid_type( $type ) ) {
69 $type = Tax_Id_Types::TYPE_OTHER;
70 }
71
72 $normalized = self::normalize_value( $value );
73 if ( '' === $normalized ) {
74 continue;
75 }
76
77 $row = array(
78 'type' => $type,
79 'value' => $normalized,
80 'country' => isset( $entry['country'] ) && '' !== $entry['country']
81 ? strtoupper( (string) $entry['country'] )
82 : Tax_Id_Types::country_for_type( $type ),
83 'label' => isset( $entry['label'] ) && '' !== $entry['label'] ? (string) $entry['label'] : null,
84 );
85
86 if ( isset( $entry['verified'] ) && \is_array( $entry['verified'] ) ) {
87 $row['verified'] = $entry['verified'];
88 }
89
90 $out[] = $row;
91 }
92
93 return self::dedupe( $out );
94 }
95
96 /**
97 * Build the meta updates that should be applied for a given TaxId[] list.
98 *
99 * Returned shape:
100 * array(
101 * 'updates' => array<string,mixed>, // meta_key => meta_value to write
102 * 'owned' => string[], // keys WCPOS now owns on this object
103 * 'verified' => array<int,array>, // verification sidecar payload
104 * )
105 *
106 * Pure logic — no I/O. The caller is responsible for applying `updates`
107 * (via `update_post_meta` / `update_user_meta`) and persisting `owned` /
108 * `verified` to the sidecar keys.
109 *
110 * @param array<int,array<string,mixed>> $tax_ids Normalised TaxId[] list.
111 * @param array<string,string> $write_map Per-type → meta-key map.
112 *
113 * @return array{updates:array<string,mixed>,owned:array<int,string>,verified:array<int,array<string,mixed>>}
114 */
115 public static function build_updates( array $tax_ids, array $write_map ): array {
116 $updates = array();
117 $owned = array();
118 $verified = array();
119
120 // Group entries by resolved meta key — multiple types can share a key
121 // (e.g. eu_vat + gb_vat → _billing_vat_number) but only one value can
122 // be persisted there. First-seen wins.
123 foreach ( $tax_ids as $entry ) {
124 $type = $entry['type'];
125 $meta_key = $write_map[ $type ] ?? '';
126 if ( '' === $meta_key ) {
127 continue;
128 }
129
130 if ( '_eu_vat_data' === $meta_key ) {
131 // Aelia structured array.
132 if ( ! isset( $updates[ $meta_key ] ) ) {
133 $updates[ $meta_key ] = array(
134 'vat_number' => $entry['value'],
135 'country' => $entry['country'] ?? '',
136 'is_valid' => isset( $entry['verified']['status'] )
137 ? 'verified' === $entry['verified']['status']
138 : false,
139 );
140 }
141 } elseif ( ! isset( $updates[ $meta_key ] ) ) {
142 $updates[ $meta_key ] = self::format_value_with_country( $entry );
143 }
144
145 if ( ! \in_array( $meta_key, $owned, true ) ) {
146 $owned[] = $meta_key;
147 }
148
149 if ( isset( $entry['verified'] ) && \is_array( $entry['verified'] ) ) {
150 $verified[] = array(
151 'type' => $type,
152 'value' => $entry['value'],
153 'verified' => $entry['verified'],
154 );
155 }
156 }
157
158 return array(
159 'updates' => $updates,
160 'owned' => $owned,
161 'verified' => $verified,
162 );
163 }
164
165 /**
166 * The per-type write map for a normalized list, or nothing for an empty one.
167 *
168 * An empty list resolves no keys, so it needs no map. Asking the detector
169 * for one ran its recent-order scan on every POS order push (the app always
170 * sends `tax_ids`, usually empty), and that scan was the straw that
171 * exhausted a 128 MB request on a 5,500-order store.
172 *
173 * @param array<int,array<string,mixed>> $normalized Normalized TaxId[] input.
174 * @param null|array<string,string> $write_map Caller-supplied map, if any.
175 *
176 * @return array<string,string>
177 */
178 private static function resolve_write_map( array $normalized, $write_map ): array {
179 if ( \is_array( $write_map ) ) {
180 return $write_map;
181 }
182 if ( array() === $normalized ) {
183 return array();
184 }
185
186 return ( new Tax_Id_Detector() )->summary()['write_map'];
187 }
188
189 /**
190 * Persist the given TaxId[] list onto a WooCommerce order.
191 *
192 * @param WC_Abstract_Order $order Order.
193 * @param array<int,mixed> $tax_ids Raw TaxId[] input.
194 * @param null|array<string,string> $write_map Optional override; defaults to detector.
195 *
196 * @return array{updates:array<string,mixed>,owned:array<int,string>,verified:array<int,array<string,mixed>>}
197 */
198 public function write_for_order( WC_Abstract_Order $order, array $tax_ids, $write_map = null ): array {
199 $normalized = self::normalize_input( $tax_ids );
200 $map = self::resolve_write_map( $normalized, $write_map );
201 $canonical = self::canonicalize_for_storage( $normalized );
202
203 $plan = self::build_updates( $normalized, $map );
204
205 // Wipe stale keys we previously owned but no longer need.
206 $previous_owned = (array) $order->get_meta( self::OWNED_KEYS_META_KEY, true );
207 $to_clear = array_diff( $previous_owned, $plan['owned'] );
208 foreach ( $to_clear as $stale_key ) {
209 $order->delete_meta_data( (string) $stale_key );
210 }
211
212 foreach ( $plan['updates'] as $meta_key => $meta_value ) {
213 $order->update_meta_data( (string) $meta_key, $meta_value );
214 }
215
216 if ( ! empty( $canonical ) ) {
217 $order->update_meta_data( Tax_Id_Reader::CANONICAL_META_KEY, wp_json_encode( array_values( $canonical ) ) );
218 } else {
219 $order->delete_meta_data( Tax_Id_Reader::CANONICAL_META_KEY );
220 }
221
222 if ( ! empty( $plan['owned'] ) ) {
223 $order->update_meta_data( self::OWNED_KEYS_META_KEY, array_values( $plan['owned'] ) );
224 } else {
225 $order->delete_meta_data( self::OWNED_KEYS_META_KEY );
226 }
227
228 if ( ! empty( $plan['verified'] ) ) {
229 $order->update_meta_data( self::VERIFIED_META_KEY, $plan['verified'] );
230 } else {
231 $order->delete_meta_data( self::VERIFIED_META_KEY );
232 }
233
234 $order->save();
235
236 return $plan;
237 }
238
239 /**
240 * Persist the given TaxId[] list onto a WP user (customer record).
241 *
242 * User meta uses the un-prefixed key (WC convention) for billing_* fields,
243 * so we strip the leading underscore before writing.
244 *
245 * @param int $user_id User ID.
246 * @param array<int,mixed> $tax_ids Raw TaxId[] input.
247 * @param null|array<string,string> $write_map Optional override.
248 *
249 * @return array{updates:array<string,mixed>,owned:array<int,string>,verified:array<int,array<string,mixed>>}
250 */
251 public function write_for_user( int $user_id, array $tax_ids, $write_map = null ): array {
252 if ( $user_id <= 0 ) {
253 return array(
254 'updates' => array(),
255 'owned' => array(),
256 'verified' => array(),
257 );
258 }
259
260 $normalized = self::normalize_input( $tax_ids );
261 $map = self::resolve_write_map( $normalized, $write_map );
262 $canonical = self::canonicalize_for_storage( $normalized );
263
264 $plan = self::build_updates( $normalized, $map );
265
266 // Wipe stale keys we previously owned but no longer need (user meta variant).
267 $previous_owned = (array) get_user_meta( $user_id, self::OWNED_KEYS_META_KEY, true );
268 $to_clear = array_diff( $previous_owned, $plan['owned'] );
269 foreach ( $to_clear as $stale_key ) {
270 $user_key = ltrim( (string) $stale_key, '_' );
271 delete_user_meta( $user_id, $user_key );
272 // Some plugins keep an underscore-prefixed shadow; clear it too.
273 delete_user_meta( $user_id, (string) $stale_key );
274 }
275
276 foreach ( $plan['updates'] as $meta_key => $meta_value ) {
277 $user_key = ltrim( (string) $meta_key, '_' );
278 update_user_meta( $user_id, $user_key, $meta_value );
279 }
280
281 if ( ! empty( $canonical ) ) {
282 update_user_meta( $user_id, Tax_Id_Reader::CANONICAL_META_KEY, wp_json_encode( array_values( $canonical ) ) );
283 } else {
284 delete_user_meta( $user_id, Tax_Id_Reader::CANONICAL_META_KEY );
285 }
286
287 if ( ! empty( $plan['owned'] ) ) {
288 update_user_meta( $user_id, self::OWNED_KEYS_META_KEY, array_values( $plan['owned'] ) );
289 } else {
290 delete_user_meta( $user_id, self::OWNED_KEYS_META_KEY );
291 }
292
293 if ( ! empty( $plan['verified'] ) ) {
294 update_user_meta( $user_id, self::VERIFIED_META_KEY, $plan['verified'] );
295 } else {
296 delete_user_meta( $user_id, self::VERIFIED_META_KEY );
297 }
298
299 return $plan;
300 }
301
302 /**
303 * Snapshot the customer's tax IDs onto an order at create time. Reads from
304 * the customer record (via Tax_Id_Reader::read_for_user) and then writes the
305 * resulting list onto the order. No-op for guest customers (id <= 0).
306 *
307 * @param WC_Abstract_Order $order Order being created.
308 * @param int $user_id Customer ID.
309 *
310 * @return array{updates:array<string,mixed>,owned:array<int,string>,verified:array<int,array<string,mixed>>}
311 */
312 public function snapshot_from_user_to_order( WC_Abstract_Order $order, int $user_id ): array {
313 if ( $user_id <= 0 ) {
314 return array(
315 'updates' => array(),
316 'owned' => array(),
317 'verified' => array(),
318 );
319 }
320
321 $reader = new Tax_Id_Reader();
322 $list = $reader->read_for_user( $user_id, $order->get_billing_country() );
323 if ( empty( $list ) ) {
324 return array(
325 'updates' => array(),
326 'owned' => array(),
327 'verified' => array(),
328 );
329 }
330
331 return $this->write_for_order( $order, $list );
332 }
333
334 /**
335 * Format a tax ID value for storage. For VAT types we prefix the country
336 * (e.g. "DE123456789") if not already present, since most VAT-aware plugins
337 * expect that form.
338 *
339 * @param array<string,mixed> $entry Tax ID entry.
340 *
341 * @return string
342 */
343 private static function format_value_with_country( array $entry ): string {
344 $value = (string) $entry['value'];
345 $country = isset( $entry['country'] ) ? (string) $entry['country'] : '';
346 $type = (string) $entry['type'];
347
348 $is_vat = \in_array(
349 $type,
350 array( Tax_Id_Types::TYPE_EU_VAT, Tax_Id_Types::TYPE_GB_VAT ),
351 true
352 );
353
354 if ( $is_vat && '' !== $country && ! preg_match( '/^[A-Z]{2}/', $value ) ) {
355 return $country . $value;
356 }
357
358 return $value;
359 }
360
361 /**
362 * Normalise a tax-ID value: trim, collapse whitespace, uppercase.
363 *
364 * @param string $value Raw value.
365 *
366 * @return string
367 */
368 private static function normalize_value( string $value ): string {
369 $value = trim( $value );
370 if ( '' === $value ) {
371 return '';
372 }
373 $value = (string) preg_replace( '/\s+/', '', $value );
374
375 return strtoupper( $value );
376 }
377
378 /**
379 * Prepare the canonical WCPOS TaxId[] sidecar. This preserves the submitted
380 * type/country metadata while matching legacy VAT storage's country-prefixed
381 * value convention for round-trip compatibility.
382 *
383 * @param array<int,array<string,mixed>> $tax_ids Tax ID list.
384 *
385 * @return array<int,array<string,mixed>>
386 */
387 private static function canonicalize_for_storage( array $tax_ids ): array {
388 $canonical = array();
389 foreach ( $tax_ids as $tax_id ) {
390 $tax_id['value'] = self::format_value_with_country( $tax_id );
391 $canonical[] = $tax_id;
392 }
393
394 return $canonical;
395 }
396
397 /**
398 * Dedupe a TaxId[] list by (type, value), keeping first occurrence.
399 *
400 * @param array<int,array<string,mixed>> $tax_ids Tax ID list.
401 *
402 * @return array<int,array<string,mixed>>
403 */
404 private static function dedupe( array $tax_ids ): array {
405 $seen = array();
406 $out = array();
407 foreach ( $tax_ids as $tax_id ) {
408 $key = $tax_id['type'] . '|' . $tax_id['value'];
409 if ( isset( $seen[ $key ] ) ) {
410 continue;
411 }
412 $seen[ $key ] = true;
413 $out[] = $tax_id;
414 }
415
416 return $out;
417 }
418 }
419