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 / Tax_Id_Writer.php

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

395 lines 12.1 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 * Persist the given TaxId[] list onto a WooCommerce order.
167 *
168 * @param WC_Abstract_Order $order Order.
169 * @param array<int,mixed> $tax_ids Raw TaxId[] input.
170 * @param null|array<string,string> $write_map Optional override; defaults to detector.
171 *
172 * @return array{updates:array<string,mixed>,owned:array<int,string>,verified:array<int,array<string,mixed>>}
173 */
174 public function write_for_order( WC_Abstract_Order $order, array $tax_ids, $write_map = null ): array {
175 $normalized = self::normalize_input( $tax_ids );
176 $map = \is_array( $write_map ) ? $write_map : ( new Tax_Id_Detector() )->summary()['write_map'];
177 $canonical = self::canonicalize_for_storage( $normalized );
178
179 $plan = self::build_updates( $normalized, $map );
180
181 // Wipe stale keys we previously owned but no longer need.
182 $previous_owned = (array) $order->get_meta( self::OWNED_KEYS_META_KEY, true );
183 $to_clear = array_diff( $previous_owned, $plan['owned'] );
184 foreach ( $to_clear as $stale_key ) {
185 $order->delete_meta_data( (string) $stale_key );
186 }
187
188 foreach ( $plan['updates'] as $meta_key => $meta_value ) {
189 $order->update_meta_data( (string) $meta_key, $meta_value );
190 }
191
192 if ( ! empty( $canonical ) ) {
193 $order->update_meta_data( Tax_Id_Reader::CANONICAL_META_KEY, wp_json_encode( array_values( $canonical ) ) );
194 } else {
195 $order->delete_meta_data( Tax_Id_Reader::CANONICAL_META_KEY );
196 }
197
198 if ( ! empty( $plan['owned'] ) ) {
199 $order->update_meta_data( self::OWNED_KEYS_META_KEY, array_values( $plan['owned'] ) );
200 } else {
201 $order->delete_meta_data( self::OWNED_KEYS_META_KEY );
202 }
203
204 if ( ! empty( $plan['verified'] ) ) {
205 $order->update_meta_data( self::VERIFIED_META_KEY, $plan['verified'] );
206 } else {
207 $order->delete_meta_data( self::VERIFIED_META_KEY );
208 }
209
210 $order->save();
211
212 return $plan;
213 }
214
215 /**
216 * Persist the given TaxId[] list onto a WP user (customer record).
217 *
218 * User meta uses the un-prefixed key (WC convention) for billing_* fields,
219 * so we strip the leading underscore before writing.
220 *
221 * @param int $user_id User ID.
222 * @param array<int,mixed> $tax_ids Raw TaxId[] input.
223 * @param null|array<string,string> $write_map Optional override.
224 *
225 * @return array{updates:array<string,mixed>,owned:array<int,string>,verified:array<int,array<string,mixed>>}
226 */
227 public function write_for_user( int $user_id, array $tax_ids, $write_map = null ): array {
228 if ( $user_id <= 0 ) {
229 return array(
230 'updates' => array(),
231 'owned' => array(),
232 'verified' => array(),
233 );
234 }
235
236 $normalized = self::normalize_input( $tax_ids );
237 $map = \is_array( $write_map ) ? $write_map : ( new Tax_Id_Detector() )->summary()['write_map'];
238 $canonical = self::canonicalize_for_storage( $normalized );
239
240 $plan = self::build_updates( $normalized, $map );
241
242 // Wipe stale keys we previously owned but no longer need (user meta variant).
243 $previous_owned = (array) get_user_meta( $user_id, self::OWNED_KEYS_META_KEY, true );
244 $to_clear = array_diff( $previous_owned, $plan['owned'] );
245 foreach ( $to_clear as $stale_key ) {
246 $user_key = ltrim( (string) $stale_key, '_' );
247 delete_user_meta( $user_id, $user_key );
248 // Some plugins keep an underscore-prefixed shadow; clear it too.
249 delete_user_meta( $user_id, (string) $stale_key );
250 }
251
252 foreach ( $plan['updates'] as $meta_key => $meta_value ) {
253 $user_key = ltrim( (string) $meta_key, '_' );
254 update_user_meta( $user_id, $user_key, $meta_value );
255 }
256
257 if ( ! empty( $canonical ) ) {
258 update_user_meta( $user_id, Tax_Id_Reader::CANONICAL_META_KEY, wp_json_encode( array_values( $canonical ) ) );
259 } else {
260 delete_user_meta( $user_id, Tax_Id_Reader::CANONICAL_META_KEY );
261 }
262
263 if ( ! empty( $plan['owned'] ) ) {
264 update_user_meta( $user_id, self::OWNED_KEYS_META_KEY, array_values( $plan['owned'] ) );
265 } else {
266 delete_user_meta( $user_id, self::OWNED_KEYS_META_KEY );
267 }
268
269 if ( ! empty( $plan['verified'] ) ) {
270 update_user_meta( $user_id, self::VERIFIED_META_KEY, $plan['verified'] );
271 } else {
272 delete_user_meta( $user_id, self::VERIFIED_META_KEY );
273 }
274
275 return $plan;
276 }
277
278 /**
279 * Snapshot the customer's tax IDs onto an order at create time. Reads from
280 * the customer record (via Tax_Id_Reader::read_for_user) and then writes the
281 * resulting list onto the order. No-op for guest customers (id <= 0).
282 *
283 * @param WC_Abstract_Order $order Order being created.
284 * @param int $user_id Customer ID.
285 *
286 * @return array{updates:array<string,mixed>,owned:array<int,string>,verified:array<int,array<string,mixed>>}
287 */
288 public function snapshot_from_user_to_order( WC_Abstract_Order $order, int $user_id ): array {
289 if ( $user_id <= 0 ) {
290 return array(
291 'updates' => array(),
292 'owned' => array(),
293 'verified' => array(),
294 );
295 }
296
297 $reader = new Tax_Id_Reader();
298 $list = $reader->read_for_user( $user_id, $order->get_billing_country() );
299 if ( empty( $list ) ) {
300 return array(
301 'updates' => array(),
302 'owned' => array(),
303 'verified' => array(),
304 );
305 }
306
307 return $this->write_for_order( $order, $list );
308 }
309
310 /**
311 * Format a tax ID value for storage. For VAT types we prefix the country
312 * (e.g. "DE123456789") if not already present, since most VAT-aware plugins
313 * expect that form.
314 *
315 * @param array<string,mixed> $entry Tax ID entry.
316 *
317 * @return string
318 */
319 private static function format_value_with_country( array $entry ): string {
320 $value = (string) $entry['value'];
321 $country = isset( $entry['country'] ) ? (string) $entry['country'] : '';
322 $type = (string) $entry['type'];
323
324 $is_vat = \in_array(
325 $type,
326 array( Tax_Id_Types::TYPE_EU_VAT, Tax_Id_Types::TYPE_GB_VAT ),
327 true
328 );
329
330 if ( $is_vat && '' !== $country && ! preg_match( '/^[A-Z]{2}/', $value ) ) {
331 return $country . $value;
332 }
333
334 return $value;
335 }
336
337 /**
338 * Normalise a tax-ID value: trim, collapse whitespace, uppercase.
339 *
340 * @param string $value Raw value.
341 *
342 * @return string
343 */
344 private static function normalize_value( string $value ): string {
345 $value = trim( $value );
346 if ( '' === $value ) {
347 return '';
348 }
349 $value = (string) preg_replace( '/\s+/', '', $value );
350
351 return strtoupper( $value );
352 }
353
354 /**
355 * Prepare the canonical WCPOS TaxId[] sidecar. This preserves the submitted
356 * type/country metadata while matching legacy VAT storage's country-prefixed
357 * value convention for round-trip compatibility.
358 *
359 * @param array<int,array<string,mixed>> $tax_ids Tax ID list.
360 *
361 * @return array<int,array<string,mixed>>
362 */
363 private static function canonicalize_for_storage( array $tax_ids ): array {
364 $canonical = array();
365 foreach ( $tax_ids as $tax_id ) {
366 $tax_id['value'] = self::format_value_with_country( $tax_id );
367 $canonical[] = $tax_id;
368 }
369
370 return $canonical;
371 }
372
373 /**
374 * Dedupe a TaxId[] list by (type, value), keeping first occurrence.
375 *
376 * @param array<int,array<string,mixed>> $tax_ids Tax ID list.
377 *
378 * @return array<int,array<string,mixed>>
379 */
380 private static function dedupe( array $tax_ids ): array {
381 $seen = array();
382 $out = array();
383 foreach ( $tax_ids as $tax_id ) {
384 $key = $tax_id['type'] . '|' . $tax_id['value'];
385 if ( isset( $seen[ $key ] ) ) {
386 continue;
387 }
388 $seen[ $key ] = true;
389 $out[] = $tax_id;
390 }
391
392 return $out;
393 }
394 }
395