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 / Sync / Order_Write_Payload.php

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

509 lines 20.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS order write payload shaping.
4 *
5 * @package WCPOS\WooCommercePOS\Sync
6 */
7
8 namespace WCPOS\WooCommercePOS\Sync;
9
10 use WC_Order_Item_Product;
11
12 /**
13 * Shapes a POS order document into the body forwarded to the STOCK wc/v3 orders
14 * controller.
15 *
16 * The v2 write surface owns two halves: the generic write protocol (envelope,
17 * replay, CAS, checkpoint) and the order-specific payload shaping that makes a
18 * POS order document survive wc/v3's strict schema and its remove-and-reapply
19 * line semantics. This class is the second half, extracted verbatim from
20 * API\V2\Write_Controller so the protocol half stays legible; the shaping rules
21 * themselves are unchanged.
22 */
23 final class Order_Write_Payload {
24 /**
25 * Shape a CREATE payload for the wc/v3 forward.
26 *
27 * @param array $payload Order payload about to be forwarded to wc/v3.
28 *
29 * @return array The forwardable payload.
30 */
31 public function for_create( array $payload ): array {
32 return $this->sanitize_order_wc_payload( $payload );
33 }
34
35 /**
36 * Shape an UPDATE payload for the wc/v3 forward.
37 *
38 * The step order is load-bearing (see the comment on the last step). The
39 * order is loaded ONCE here and handed to every step that needs it; each
40 * step still no-ops when the id does not resolve, exactly as it did when it
41 * loaded the order itself.
42 *
43 * @param int $order_id Resolved order id.
44 * @param array $payload Update payload about to be forwarded to wc/v3.
45 *
46 * @return array The forwardable payload.
47 */
48 public function for_update( int $order_id, array $payload ): array {
49 $order = wc_get_order( $order_id );
50 if ( ! $order instanceof \WC_Abstract_Order ) {
51 $order = false;
52 }
53 $payload = $this->reconcile_order_item_ids( $order, $payload );
54 $payload = $this->remove_omitted_order_items( $order, $payload );
55 $payload = $this->reconcile_order_coupon_lines( $order, $payload );
56 $payload = $this->sanitize_order_wc_payload( $payload );
57 // Runs last: it reads the FORWARDED line shape, after normalize_line_item_product_identity
58 // has already resolved the posted sku (which outranks the ids in wc/v3's get_product_id).
59 return $this->drop_unchanged_variation_line_identity( $order, $payload );
60 }
61
62 /**
63 * WC-strict-schema tolerance for POS order payloads.
64 *
65 * The v1 surface relaxed the wc/v3 order schema for POS realities (walk-in
66 * sales have no email; client line items carry a nullable parent_name that
67 * WC recomputes anyway) by editing the POS controller's schema — see
68 * V1\Orders_Controller::wcpos_get_item_schema(). The v2 write surface
69 * forwards to the STOCK wc/v3 controller, whose strict schema turns those
70 * POS-legit values into rest_invalid_param 400s (a rejected CREATE then
71 * strands the record client-side: every later update 404s). Express the same
72 * tolerance by dropping the values WC would reject:
73 * - billing.email '' / null → dropped (absent means "no email"; '' fails the format check)
74 * - line_items[n].parent_name null → dropped (schema wants string; the server recomputes it)
75 * - meta_data display fields → dropped (WC derives them and ignores them on write)
76 * - line_items[].image → dropped (server-derived display data; acks serialize
77 * image.id as '' for imageless products, which wc/v3's integer schema rejects
78 * when a client re-pushes its full document)
79 *
80 * @param array $payload Order payload about to be forwarded to wc/v3.
81 *
82 * @return array The payload with WC-rejected POS values dropped.
83 */
84 private function sanitize_order_wc_payload( array $payload ): array {
85 $payload = $this->recover_any_variation_attributes( $payload );
86 $payload = $this->without_empty_billing_email( $payload );
87 if ( isset( $payload['line_items'] ) && is_array( $payload['line_items'] ) ) {
88 foreach ( $payload['line_items'] as $i => $line ) {
89 if ( is_array( $line ) && array_key_exists( 'parent_name', $line ) && null === $line['parent_name'] ) {
90 unset( $payload['line_items'][ $i ]['parent_name'] );
91 }
92 // image is a server-derived display field: acks serialize it with
93 // image.id '' for imageless products, which fails wc/v3's integer
94 // schema when the client re-pushes its full document.
95 if ( is_array( $line ) && array_key_exists( 'image', $line ) ) {
96 unset( $payload['line_items'][ $i ]['image'] );
97 }
98 }
99 $payload['line_items'] = $this->normalize_line_item_product_identity( $payload['line_items'] );
100 }
101 if ( isset( $payload['meta_data'] ) && is_array( $payload['meta_data'] ) ) {
102 foreach ( $payload['meta_data'] as $i => $entry ) {
103 if ( is_array( $entry ) ) {
104 unset( $payload['meta_data'][ $i ]['display_key'], $payload['meta_data'][ $i ]['display_value'] );
105 }
106 }
107 }
108 foreach ( array( 'line_items', 'shipping_lines', 'fee_lines', 'coupon_lines' ) as $line_type ) {
109 if ( ! isset( $payload[ $line_type ] ) || ! is_array( $payload[ $line_type ] ) ) {
110 continue;
111 }
112 foreach ( $payload[ $line_type ] as $i => $line ) {
113 if ( ! is_array( $line ) || ! isset( $line['meta_data'] ) || ! is_array( $line['meta_data'] ) ) {
114 continue;
115 }
116 foreach ( $line['meta_data'] as $j => $entry ) {
117 if ( is_array( $entry ) ) {
118 unset( $payload[ $line_type ][ $i ]['meta_data'][ $j ]['display_key'], $payload[ $line_type ][ $i ]['meta_data'][ $j ]['display_value'] );
119 }
120 }
121 }
122 }
123 return $payload;
124 }
125
126 /**
127 * Drop a billing email value that wc/v3 rejects but POS treats as absent.
128 *
129 * Shared with customer writes so both lanes retain the v1 walk-in rule.
130 *
131 * @param array $payload Payload about to be forwarded to wc/v3.
132 *
133 * @return array Payload with an empty billing email removed.
134 */
135 public function without_empty_billing_email( array $payload ): array {
136 if ( isset( $payload['billing'] ) && is_array( $payload['billing'] )
137 && array_key_exists( 'email', $payload['billing'] )
138 && ( '' === $payload['billing']['email'] || null === $payload['billing']['email'] ) ) {
139 unset( $payload['billing']['email'] );
140 }
141 return $payload;
142 }
143
144 /**
145 * Recover choices for variation attributes whose catalog value is "any".
146 *
147 * @param array $payload Order payload about to be forwarded to wc/v3.
148 * @return array Payload with recoverable real attribute meta appended.
149 */
150 private function recover_any_variation_attributes( array $payload ): array {
151 if ( empty( $payload['line_items'] ) || ! is_array( $payload['line_items'] ) ) {
152 return $payload;
153 }
154 foreach ( $payload['line_items'] as $i => $line ) {
155 if ( empty( $line['variation_id'] ) || empty( $line['meta_data'] ) || ! is_array( $line['meta_data'] ) ) {
156 continue;
157 }
158 $product = wc_get_product( $line['product_id'] ?? 0 );
159 if ( ! $product ) {
160 continue;
161 }
162 $parent_attributes = $product->get_attributes();
163 foreach ( wc_get_product_variation_attributes( $line['variation_id'] ) as $key => $value ) {
164 if ( '' !== $value ) {
165 continue;
166 }
167 $slug = str_replace( 'attribute_', '', $key );
168 if ( ! isset( $parent_attributes[ $slug ] ) ) {
169 continue;
170 }
171 foreach ( $line['meta_data'] as $meta ) {
172 if ( is_array( $meta ) && isset( $meta['key'] ) && $slug === $meta['key'] ) {
173 continue 2;
174 }
175 }
176 $name = $parent_attributes[ $slug ]['name'] ?? $slug;
177 if ( $name === $slug ) {
178 $name = wc_attribute_label( $slug );
179 }
180 foreach ( $line['meta_data'] as $meta ) {
181 if ( is_array( $meta ) && isset( $meta['display_key'], $meta['display_value'] )
182 && $meta['display_key'] === $name && $meta['display_value'] ) {
183 $payload['line_items'][ $i ]['meta_data'][] = array(
184 'key' => $slug,
185 'value' => $meta['display_value'],
186 );
187 break;
188 }
189 }
190 }
191 }
192 return $payload;
193 }
194
195 /**
196 * Prefix for a collision-resistant payload-only sku. Stock wc/v3 requires a
197 * product_id OR a sku on line-item create, so a misc line (product_id 0) must
198 * carry one — but a real sku would resolve to a catalog product. The posted
199 * line sku is lookup-only in wc/v3 (never persisted), so the sentinel leaves
200 * no trace on the stored order.
201 */
202 private const MISC_LINE_SKU_SENTINEL = 'wcpos-misc-item-no-sku-lookup';
203
204 /**
205 * Restore the v1 line-item product-identity semantics on the forwarded payload
206 * (issue #1403 row 1). Stock `WC_REST_Orders_V2_Controller::get_product_id`
207 * prefers a posted `sku` over the posted `product_id` and throws when both are
208 * empty on create; v1 overrode it to trust the posted ids (duplicated-sku
209 * catalogs) and to pass misc/custom lines (product_id 0) straight through,
210 * stamping the typed sku as `_sku` item meta (`maybe_set_item_meta_data`).
211 * Express the same at the payload seam:
212 * - a line with a real product/variation id drops its `sku` (ids are authoritative);
213 * - a misc line (product_id === 0, NOT the null-as-delete marker) forwards the
214 * non-colliding sentinel sku and carries its typed sku as `_sku` meta, which
215 * the synthetic-product read path (Orders::order_item_product) serves back.
216 *
217 * @param array $line_items Posted line items.
218 *
219 * @return array The normalized line items.
220 */
221 private function normalize_line_item_product_identity( array $line_items ): array {
222 foreach ( $line_items as $i => $line ) {
223 if ( ! is_array( $line ) ) {
224 continue;
225 }
226 $product_id = array_key_exists( 'product_id', $line ) ? $line['product_id'] : null;
227 $is_misc = null !== $product_id && is_numeric( $product_id ) && 0.0 === (float) $product_id;
228 if ( ! $is_misc ) {
229 // v1 stripped the sku from EVERY non-misc line shape — partial update
230 // lines without a product_id included: the posted ids (or, for partial
231 // updates, the stored line) are authoritative, never a sku lookup. A
232 // non-numeric product_id also lands here, so wc/v3's own schema
233 // validation rejects it instead of a sku silently rebinding the line.
234 unset( $line_items[ $i ]['sku'] );
235 continue;
236 }
237 // Misc/custom product line (product_id exactly 0 — a null product_id is
238 // wc/v3's remove-this-line marker and was excluded above). Mirror v1's
239 // maybe_set_item_meta_data: when the line carries a sku key, its typed
240 // value (including '' — an explicit clear) becomes the single `_sku`
241 // item meta, replacing any stale `_sku` in a pulled order document.
242 if ( array_key_exists( 'sku', $line ) ) {
243 if ( ! is_string( $line['sku'] ) ) {
244 continue;
245 }
246 if ( ! array_key_exists( 'meta_data', $line ) || is_array( $line['meta_data'] ) ) {
247 $typed_sku = trim( $line['sku'] );
248 $meta = $line['meta_data'] ?? array();
249 $has_sku_meta = false;
250 foreach ( $meta as $j => $entry ) {
251 if ( is_array( $entry ) && '_sku' === Meta_Entry::key( $entry ) ) {
252 $meta[ $j ]['value'] = $typed_sku;
253 $has_sku_meta = true;
254 }
255 }
256 if ( ! $has_sku_meta ) {
257 $meta[] = array(
258 'key' => '_sku',
259 'value' => $typed_sku,
260 );
261 }
262 $line_items[ $i ]['meta_data'] = $meta;
263 }
264 }
265 $line_items[ $i ]['sku'] = $this->misc_line_sentinel_sku();
266 }
267
268 return $line_items;
269 }
270
271 /**
272 * A fresh sentinel sku for the forwarded line. A catalog miss probe cannot
273 * make the later wc/v3 lookup atomic, so use a UUID suffix that makes an
274 * independently assigned catalog collision negligibly likely.
275 *
276 * @return string
277 */
278 private function misc_line_sentinel_sku(): string {
279 return self::MISC_LINE_SKU_SENTINEL . '-' . wp_generate_uuid4();
280 }
281
282 /**
283 * Restore missing order item ids from each line type's stable POS UUID.
284 *
285 * Ambiguous or absent UUID matches deliberately remain creates in wc/v3.
286 *
287 * @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve.
288 * @param array $payload Update payload about to be forwarded.
289 * @return array Reconciled payload.
290 */
291 private function reconcile_order_item_ids( $order, array $payload ): array {
292 if ( ! $order ) {
293 return $payload;
294 }
295
296 $types = array(
297 'line_items' => 'line_item',
298 'fee_lines' => 'fee',
299 'shipping_lines' => 'shipping',
300 );
301 foreach ( $types as $payload_key => $item_type ) {
302 if ( ! isset( $payload[ $payload_key ] ) || ! is_array( $payload[ $payload_key ] ) ) {
303 continue;
304 }
305 $matches = array();
306 foreach ( $order->get_items( $item_type ) as $item ) {
307 $uuid = $item->get_meta( Pos_Uuid::META_KEY, true );
308 if ( is_string( $uuid ) && '' !== $uuid ) {
309 $matches[ $uuid ][] = $item->get_id();
310 }
311 }
312 foreach ( $payload[ $payload_key ] as $index => $line ) {
313 if ( ! is_array( $line ) || ! empty( $line['id'] ) || ! is_array( $line['meta_data'] ?? null ) ) {
314 continue;
315 }
316 foreach ( $line['meta_data'] as $meta ) {
317 if ( is_array( $meta ) && Pos_Uuid::META_KEY === Meta_Entry::key( $meta ) && is_string( Meta_Entry::value( $meta ) ) ) {
318 $uuid = Meta_Entry::value( $meta );
319 if ( 1 === count( $matches[ $uuid ] ?? array() ) ) {
320 $payload[ $payload_key ][ $index ]['id'] = $matches[ $uuid ][0];
321 }
322 break;
323 }
324 }
325 }
326 }
327
328 return $payload;
329 }
330
331 /**
332 * Drop the redundant product identity from update lines whose variation binding
333 * is unchanged — the v2 port of V1\Orders_Controller::prepare_line_items' dedupe.
334 *
335 * Stock `WC_REST_Orders_V2_Controller::prepare_line_items` compares products by
336 * OBJECT identity (`$product !== $item->get_product()`), which is always true, so
337 * every posted line re-runs `WC_Order_Item_Product::set_product()`. For a variation
338 * that calls `set_variation()` → `add_meta_data( 'pa_size', …, true )`, which NULLs
339 * the stored attribute row (marking it for deletion) and appends a fresh, id-less
340 * copy. `maybe_set_item_meta_data()` then runs `update_meta_data( 'pa_size', …, <id> )`
341 * for the posted meta entry, which finds the nulled row BY ID and restores its value —
342 * cancelling the delete while the appended copy is still inserted. Net effect: a
343 * full-document re-push of an acknowledged variation order grows one duplicate
344 * `pa_*` meta row per push (#1456).
345 *
346 * v1 fixed this after the fact by pruning the duplicates. At the v2 forward seam the
347 * cause is cheaper to remove: when the posted line already resolves to the SAME
348 * variation the stored item is bound to, the product binding is a no-op, so drop
349 * `product_id`/`variation_id` from the forwarded line. `get_product_id()` then returns
350 * 0 on update, `wc_get_product( 0 )` is false and the whole `set_product()` branch is
351 * skipped — the stored attribute rows are updated in place, ids and all, so the
352 * acknowledgement is byte-stable across re-pushes. Lines that genuinely re-bind to a
353 * different variation still forward their identity and take WC's normal path.
354 *
355 * @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve.
356 * @param array $payload Reconciled update payload.
357 * @return array Payload with no-op variation identity removed from unchanged lines.
358 */
359 private function drop_unchanged_variation_line_identity( $order, array $payload ): array {
360 if ( ! isset( $payload['line_items'] ) || ! is_array( $payload['line_items'] ) ) {
361 return $payload;
362 }
363 if ( ! $order ) {
364 return $payload;
365 }
366 foreach ( $payload['line_items'] as $i => $line ) {
367 if ( ! is_array( $line ) || empty( $line['id'] ) || ! is_numeric( $line['id'] ) ) {
368 continue;
369 }
370 $item = $order->get_item( (int) $line['id'] );
371 if ( ! $item instanceof WC_Order_Item_Product || $item->get_variation_id() <= 0 ) {
372 continue;
373 }
374 // A posted sku wins over the ids in wc/v3's get_product_id(), so a line
375 // carrying one is not an unchanged binding as far as WC is concerned.
376 if ( ! empty( $line['sku'] ) ) {
377 continue;
378 }
379 // wc/v3's remove-this-line marker is `product_id: null` (item_is_null). The
380 // WCPOS client posts it on the FULL settled line, acked variation_id and all, so a
381 // removed variation line looks exactly like an unchanged binding from here.
382 // `isset()` is false for null, so the check below would let it through and
383 // unset the very key wc/v3 removes on — the line survived every save.
384 if ( array_key_exists( 'product_id', $line ) && null === $line['product_id'] ) {
385 continue;
386 }
387 if ( ! isset( $line['variation_id'] ) || ! is_numeric( $line['variation_id'] )
388 || (int) $line['variation_id'] !== $item->get_variation_id() ) {
389 continue;
390 }
391 if ( isset( $line['product_id'] ) && ( ! is_numeric( $line['product_id'] ) || (int) $line['product_id'] !== $item->get_product_id() ) ) {
392 continue;
393 }
394 unset( $payload['line_items'][ $i ]['product_id'], $payload['line_items'][ $i ]['variation_id'] );
395 }
396 return $payload;
397 }
398
399 /**
400 * Add wc/v3 deletion markers for stored items omitted from posted line collections.
401 *
402 * @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve.
403 * @param array $payload Reconciled update payload.
404 * @return array Payload containing deletion markers for omitted items.
405 */
406 private function remove_omitted_order_items( $order, array $payload ): array {
407 if ( ! $order ) {
408 return $payload;
409 }
410 $types = array(
411 'line_items' => array( 'line_item', 'product_id' ),
412 'fee_lines' => array( 'fee', 'name' ),
413 'shipping_lines' => array( 'shipping', 'method_id' ),
414 );
415 foreach ( $types as $payload_key => $type ) {
416 if ( ! array_key_exists( $payload_key, $payload ) || ! is_array( $payload[ $payload_key ] ) ) {
417 continue;
418 }
419 $stored_items = $order->get_items( $type[0] );
420 $posted_ids = array();
421 foreach ( $payload[ $payload_key ] as $line ) {
422 if ( is_array( $line ) && ! empty( $line['id'] ) && is_numeric( $line['id'] ) ) {
423 $posted_ids[] = (int) $line['id'];
424 continue;
425 }
426 $uuid = is_array( $line ) && is_array( $line['meta_data'] ?? null )
427 ? Pos_Uuid::read_valid_uuid_from_meta( $line['meta_data'] )
428 : '';
429 foreach ( $stored_items as $item ) {
430 if ( '' !== $uuid && $uuid === $item->get_meta( Pos_Uuid::META_KEY, true ) ) {
431 $posted_ids[] = $item->get_id();
432 }
433 }
434 }
435 foreach ( $stored_items as $item ) {
436 if ( ! in_array( $item->get_id(), $posted_ids, true ) ) {
437 $payload[ $payload_key ][] = array(
438 'id' => $item->get_id(),
439 $type[1] => null,
440 );
441 }
442 }
443 }
444 return $payload;
445 }
446
447 /**
448 * Reconcile a full-document order update's coupon_lines with the stored order —
449 * the v2 port of V1\Orders_Controller::calculate_coupons (issue #1403 row 3).
450 *
451 * Stock wc/v3 treats coupon_lines as remove-and-reapply and throws
452 * `woocommerce_rest_coupon_item_id_readonly` (400) on any line carrying an `id` —
453 * but the POS always pushes the complete order document, whose coupon_lines carry
454 * the ids from the previous ack, so every update of a couponed order would fail.
455 * Mirror the v1 semantics at the forward seam: when the requested coupon code-set
456 * equals the order's current coupons, drop coupon_lines from the forward entirely
457 * (skip the recalculation, preserving stable coupon line ids — v1 returned false);
458 * when the sets differ, strip the ids and let wc/v3 do its remove-and-reapply.
459 *
460 * @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve.
461 * @param array $payload Update payload about to be forwarded.
462 *
463 * @return array The payload with coupon_lines reconciled.
464 */
465 private function reconcile_order_coupon_lines( $order, array $payload ): array {
466 if ( ! isset( $payload['coupon_lines'] ) || ! is_array( $payload['coupon_lines'] ) ) {
467 return $payload;
468 }
469 if ( ! $order ) {
470 return $payload;
471 }
472
473 $requested_codes = array();
474 $all_lines_valid = true;
475 foreach ( $payload['coupon_lines'] as $line ) {
476 $code = is_array( $line ) ? ( $line['code'] ?? null ) : null;
477 if ( ! is_string( $code ) || '' === trim( $code ) ) {
478 // A malformed line must reach wc/v3 so its canonical "Coupon code is
479 // required" validation fires — skipping here would silently ack it.
480 $all_lines_valid = false;
481 break;
482 }
483 $requested_codes[] = wc_strtolower( wc_format_coupon_code( wc_clean( $code ) ) );
484 }
485 $existing_codes = array_map(
486 static function ( $coupon ) {
487 return wc_strtolower( $coupon->get_code() );
488 },
489 array_values( $order->get_coupons() )
490 );
491 sort( $requested_codes );
492 sort( $existing_codes );
493
494 if ( $all_lines_valid && $requested_codes === $existing_codes ) {
495 unset( $payload['coupon_lines'] );
496 return $payload;
497 }
498
499 foreach ( $payload['coupon_lines'] as $i => $line ) {
500 if ( is_array( $line ) ) {
501 unset( $payload['coupon_lines'][ $i ]['id'] );
502 }
503 }
504 $payload['coupon_lines'] = array_values( $payload['coupon_lines'] );
505
506 return $payload;
507 }
508 }
509