PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
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 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / Sync / Order_Write_Payload.php

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

673 lines 28.6 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 use WCPOS\WooCommercePOS\Services\Tax_Id_Reader;
12 use WCPOS\WooCommercePOS\Services\Tax_Id_Writer;
13 use WP_Error;
14
15 /**
16 * Shapes a POS order document into the body forwarded to the STOCK wc/v3 orders
17 * controller.
18 *
19 * V1 = API\V1\Orders_Controller; V2 = API\V2\Writers\Order_Writer.
20 * V1 shapes create_item/update_item through for_create/for_partial_update;
21 * V2 shapes prepare_create/prepare_order_update_after_read through for_create/for_update.
22 *
23 * Shared through this class (both lanes):
24 * - Product identity and misc SKU: V1 create_item/update_item shaping; V2 create/update shaping.
25 * - "Any" attribute recovery: V1 create_item/update_item shaping; V2 create/update shaping; posted keys win.
26 * - Unchanged identity dedupe: V1 update_item shaping; V2 update shaping skips set_product() for an unchanged binding (#1456 duplicate rows; catalog name/tax_class resets).
27 * - Item-UUID ID reconciliation: V1 update_item shaping; V2 update shaping restores uniquely matched IDs.
28 * - Stored identity for id-only update lines: both update shapes fill product/variation ids from the stored item before the rules above run.
29 * - Display-field and image drops: V1 create_item/update_item shaping; V2 create/update shaping.
30 * - Client date: V1 create_item filter and V2 prepare_create use validate_client_created_gmt.
31 * - Tax-ID persistence: V1 create_item/update_item response refresh and V2 persist use persist_tax_ids.
32 *
33 * Deliberately lane-specific (ruled 2026-09-18):
34 * - Empty coupon code: V1 calculate_coupons skips it so released 1.x clients do not strand orders; V2 forwards for WC's 400.
35 * - Omitted items: V1 retains WC partial-document semantics; V2 adds deletion markers; not all 1.x clients post full documents.
36 * - Incomplete tax IDs: V1 coerces, V2 rejects; parked until Paul's #1724 rework.
37 *
38 * v1's schema relaxations in get_item_schema() are the validation-time half of the
39 * same tolerance this class expresses at the forward seam. V1 updates retain an
40 * explicit empty billing email; V2 drops it here and its writer clears it explicitly.
41 */
42 final class Order_Write_Payload {
43 /**
44 * Shape a CREATE payload for the wc/v3 forward.
45 *
46 * @param array $payload Order payload about to be forwarded to wc/v3.
47 *
48 * @return array The forwardable payload.
49 */
50 public function for_create( array $payload ): array {
51 return $this->sanitize_order_wc_payload( $this->without_empty_billing_email( $payload ) );
52 }
53
54 /**
55 * Shape an UPDATE payload for the wc/v3 forward.
56 *
57 * The step order is load-bearing (see the comment on the last step). The
58 * order is loaded ONCE here and handed to every step that needs it; each
59 * step still no-ops when the id does not resolve, exactly as it did when it
60 * loaded the order itself.
61 *
62 * @param int $order_id Resolved order id.
63 * @param array $payload Update payload about to be forwarded to wc/v3.
64 *
65 * @return array The forwardable payload.
66 */
67 public function for_update( int $order_id, array $payload ): array {
68 $order = wc_get_order( $order_id );
69 if ( ! $order instanceof \WC_Abstract_Order ) {
70 $order = false;
71 }
72 $payload = $this->reconcile_order_item_ids( $order, $payload );
73 $payload = $this->hydrate_line_identity_from_stored( $order, $payload );
74 $payload = $this->remove_omitted_order_items( $order, $payload );
75 $payload = $this->reconcile_order_coupon_lines( $order, $payload );
76 $payload = $this->without_empty_billing_email( $payload );
77 $payload = $this->sanitize_order_wc_payload( $payload );
78 // Runs last: it reads the FORWARDED line shape, after normalize_line_item_product_identity
79 // has already resolved the posted sku (which outranks the ids in wc/v3's get_product_id).
80 return $this->drop_unchanged_line_identity( $order, $payload );
81 }
82
83 /**
84 * Shape the v1 lane's partial update; see for_update for the full-document shape.
85 *
86 * The 2026-09-18 rulings omit omission markers and coupon reconciliation here.
87 * Retain billing.email: '' so WooCommerce applies the deliberate clear directly.
88 *
89 * @param int $order_id Resolved order ID.
90 * @param array $payload Partial update payload.
91 * @return array The forwardable payload.
92 */
93 public function for_partial_update( int $order_id, array $payload ): array {
94 $order = wc_get_order( $order_id );
95 if ( ! $order instanceof \WC_Abstract_Order ) {
96 $order = false;
97 }
98 $payload = $this->reconcile_order_item_ids( $order, $payload );
99 $payload = $this->hydrate_line_identity_from_stored( $order, $payload );
100 $payload = $this->sanitize_order_wc_payload( $payload );
101 // Runs last for the same load-bearing reason as for_update: inspect the forwarded identity.
102 return $this->drop_unchanged_line_identity( $order, $payload );
103 }
104
105 /**
106 * Fill an update line's product identity from the stored item it names.
107 *
108 * A line posted by `id` without `product_id` and/or `variation_id` is a
109 * partial-document edit of a stored line. The identity rules below read the
110 * POSTED identity (the "any" recovery needs BOTH the parent and the variation;
111 * the misc-sku rule needs to tell a misc line from a catalog one), so without it
112 * a display-only attribute choice or a retyped misc sku was silently dropped —
113 * the deleted v1 override read the stored item instead. Each ABSENT key is
114 * filled on its own, but only while every POSTED key still matches the stored
115 * binding: a posted id that differs is a re-bind (say, a variation line moved to
116 * a simple product by posting the new product_id alone), and wc/v3 ranks a
117 * variation id above a product id, so handing that line its old variation_id
118 * would silently keep the old binding. A posted `product_id: null` is wc/v3's
119 * remove-this-line marker, which leaves the whole line alone.
120 * drop_unchanged_line_identity removes the filled identity again when it matches
121 * the stored binding, so an id-only edit forwards id-only, as it always did.
122 *
123 * @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve.
124 * @param array $payload Update payload with ids reconciled.
125 * @return array Payload whose id-only product lines carry their stored identity.
126 */
127 private function hydrate_line_identity_from_stored( $order, array $payload ): array {
128 if ( ! $order || ! isset( $payload['line_items'] ) || ! is_array( $payload['line_items'] ) ) {
129 return $payload;
130 }
131 foreach ( $payload['line_items'] as $i => $line ) {
132 if ( ! is_array( $line ) || empty( $line['id'] ) || ! is_numeric( $line['id'] ) ) {
133 continue;
134 }
135 if ( array_key_exists( 'product_id', $line ) && null === $line['product_id'] ) {
136 continue;
137 }
138 $needs_product = ! array_key_exists( 'product_id', $line );
139 $needs_variation = ! array_key_exists( 'variation_id', $line );
140 if ( ! $needs_product && ! $needs_variation ) {
141 continue;
142 }
143 $item = $order->get_item( (int) $line['id'] );
144 if ( ! $item instanceof WC_Order_Item_Product ) {
145 continue;
146 }
147 // A posted key that differs from the stored binding is a re-bind: forward as posted.
148 if ( ! $needs_product && ( ! is_numeric( $line['product_id'] ) || (int) $line['product_id'] !== $item->get_product_id() ) ) {
149 continue;
150 }
151 if ( ! $needs_variation && ( ! is_numeric( $line['variation_id'] ) || (int) $line['variation_id'] !== $item->get_variation_id() ) ) {
152 continue;
153 }
154 if ( $needs_product ) {
155 $payload['line_items'][ $i ]['product_id'] = $item->get_product_id();
156 }
157 if ( $needs_variation ) {
158 $payload['line_items'][ $i ]['variation_id'] = $item->get_variation_id();
159 }
160 }
161 return $payload;
162 }
163
164 /**
165 * Validate the client creation time; bare GMT values are UTC, not store time.
166 *
167 * @param array $payload Original order document (raw JSON on v1).
168 * @return int|null|WP_Error UTC timestamp, null when absent/empty, or a 400 error.
169 */
170 public function validate_client_created_gmt( array $payload ) {
171 if ( ! isset( $payload['date_created_gmt'] ) ) {
172 return null;
173 }
174 if ( ! is_scalar( $payload['date_created_gmt'] ) ) {
175 return $this->invalid_created_gmt();
176 }
177 $value = wc_clean( wp_unslash( (string) $payload['date_created_gmt'] ) );
178 if ( '' === $value ) {
179 return null;
180 }
181 $timestamp = 1 === preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/i', $value )
182 ? rest_parse_date( 'Z' === strtoupper( substr( $value, -1 ) ) ? $value : $value . 'Z', true ) : false;
183 if ( false === $timestamp ) {
184 return $this->invalid_created_gmt();
185 }
186 return $timestamp > time() + DAY_IN_SECONDS
187 ? new WP_Error( 'woocommerce_pos_rest_future_date_created_gmt', __( 'date_created_gmt cannot be more than 24 hours in the future.', 'woocommerce-pos' ), array( 'status' => 400 ) )
188 : $timestamp;
189 }
190
191 /** Build the stable invalid create timestamp error. */
192 private function invalid_created_gmt(): WP_Error {
193 return new WP_Error( 'woocommerce_pos_rest_invalid_date_created_gmt', __( 'date_created_gmt must be a valid ISO 8601 UTC date.', 'woocommerce-pos' ), array( 'status' => 400 ) );
194 }
195
196 /**
197 * Persist explicit tax IDs, or snapshot the customer only on create.
198 *
199 * The v1 controller uses the read-back to refresh its already-built response.
200 *
201 * @param int $id Saved order ID.
202 * @param array $payload Original order document; an empty tax_ids array clears IDs.
203 * @param bool $is_create Whether to snapshot when tax_ids is absent.
204 * @return array|null Stored tax IDs, or null when the order does not exist.
205 */
206 public function persist_tax_ids( int $id, array $payload, bool $is_create ): ?array {
207 $order = wc_get_order( $id );
208 if ( ! $order ) {
209 return null;
210 }
211 if ( is_array( $payload['tax_ids'] ?? null ) ) {
212 ( new Tax_Id_Writer() )->write_for_order( $order, $payload['tax_ids'] );
213 } elseif ( $is_create && $order->get_customer_id() > 0 ) {
214 ( new Tax_Id_Writer() )->snapshot_from_user_to_order( $order, $order->get_customer_id() );
215 }
216 return ( new Tax_Id_Reader() )->read_for_order( $order );
217 }
218
219 /**
220 * WC-strict-schema tolerance for POS order payloads.
221 *
222 * The v1 surface relaxed the wc/v3 order schema for POS realities (walk-in
223 * sales have no email; client line items carry a nullable parent_name that
224 * WC recomputes anyway) by editing the POS controller's schema — see
225 * V1\Orders_Controller::get_item_schema(). The v2 write surface
226 * forwards to the STOCK wc/v3 controller, whose strict schema turns those
227 * POS-legit values into rest_invalid_param 400s (a rejected CREATE then
228 * strands the record client-side: every later update 404s). Express the same
229 * tolerance by dropping the values WC would reject:
230 * - line_items[n].parent_name null → dropped (schema wants string; the server recomputes it)
231 * - meta_data display fields → dropped (WC derives them and ignores them on write)
232 * - line_items[].image → dropped (server-derived display data; acks serialize
233 * image.id as '' for imageless products, which wc/v3's integer schema rejects
234 * when a client re-pushes its full document)
235 * The billing-email drop is not here: for_create and for_update apply it, the
236 * partial-update shape keeps '' so WooCommerce applies the deliberate clear.
237 *
238 * @param array $payload Order payload about to be forwarded to wc/v3.
239 *
240 * @return array The payload with WC-rejected POS values dropped.
241 */
242 private function sanitize_order_wc_payload( array $payload ): array {
243 $payload = $this->recover_any_variation_attributes( $payload );
244 if ( isset( $payload['line_items'] ) && is_array( $payload['line_items'] ) ) {
245 foreach ( $payload['line_items'] as $i => $line ) {
246 if ( is_array( $line ) && array_key_exists( 'parent_name', $line ) && null === $line['parent_name'] ) {
247 unset( $payload['line_items'][ $i ]['parent_name'] );
248 }
249 // image is a server-derived display field: acks serialize it with
250 // image.id '' for imageless products, which fails wc/v3's integer
251 // schema when the client re-pushes its full document.
252 if ( is_array( $line ) && array_key_exists( 'image', $line ) ) {
253 unset( $payload['line_items'][ $i ]['image'] );
254 }
255 }
256 $payload['line_items'] = $this->normalize_line_item_product_identity( $payload['line_items'] );
257 }
258 if ( isset( $payload['meta_data'] ) && is_array( $payload['meta_data'] ) ) {
259 foreach ( $payload['meta_data'] as $i => $entry ) {
260 if ( is_array( $entry ) ) {
261 unset( $payload['meta_data'][ $i ]['display_key'], $payload['meta_data'][ $i ]['display_value'] );
262 }
263 }
264 }
265 foreach ( array( 'line_items', 'shipping_lines', 'fee_lines', 'coupon_lines' ) as $line_type ) {
266 if ( ! isset( $payload[ $line_type ] ) || ! is_array( $payload[ $line_type ] ) ) {
267 continue;
268 }
269 foreach ( $payload[ $line_type ] as $i => $line ) {
270 if ( ! is_array( $line ) || ! isset( $line['meta_data'] ) || ! is_array( $line['meta_data'] ) ) {
271 continue;
272 }
273 foreach ( $line['meta_data'] as $j => $entry ) {
274 if ( is_array( $entry ) ) {
275 unset( $payload[ $line_type ][ $i ]['meta_data'][ $j ]['display_key'], $payload[ $line_type ][ $i ]['meta_data'][ $j ]['display_value'] );
276 }
277 }
278 }
279 }
280 return $payload;
281 }
282
283 /**
284 * Drop a billing email value that wc/v3 rejects but POS treats as absent.
285 *
286 * Shared with customer writes so both lanes retain the v1 walk-in rule.
287 *
288 * @param array $payload Payload about to be forwarded to wc/v3.
289 *
290 * @return array Payload with an empty billing email removed.
291 */
292 public function without_empty_billing_email( array $payload ): array {
293 if ( isset( $payload['billing'] ) && is_array( $payload['billing'] )
294 && array_key_exists( 'email', $payload['billing'] )
295 && ( '' === $payload['billing']['email'] || null === $payload['billing']['email'] ) ) {
296 unset( $payload['billing']['email'] );
297 }
298 return $payload;
299 }
300
301 /**
302 * Recover choices for variation attributes whose catalog value is "any".
303 *
304 * @param array $payload Order payload about to be forwarded to wc/v3.
305 * @return array Payload with recoverable real attribute meta appended.
306 */
307 private function recover_any_variation_attributes( array $payload ): array {
308 if ( empty( $payload['line_items'] ) || ! is_array( $payload['line_items'] ) ) {
309 return $payload;
310 }
311 foreach ( $payload['line_items'] as $i => $line ) {
312 if ( empty( $line['variation_id'] ) || empty( $line['meta_data'] ) || ! is_array( $line['meta_data'] ) ) {
313 continue;
314 }
315 $product = wc_get_product( $line['product_id'] ?? 0 );
316 if ( ! $product ) {
317 continue;
318 }
319 $parent_attributes = $product->get_attributes();
320 foreach ( wc_get_product_variation_attributes( $line['variation_id'] ) as $key => $value ) {
321 if ( '' !== $value ) {
322 continue;
323 }
324 $slug = str_replace( 'attribute_', '', $key );
325 if ( ! isset( $parent_attributes[ $slug ] ) ) {
326 continue;
327 }
328 foreach ( $line['meta_data'] as $meta ) {
329 if ( is_array( $meta ) && isset( $meta['key'] ) && $slug === $meta['key'] ) {
330 continue 2;
331 }
332 }
333 $name = $parent_attributes[ $slug ]['name'] ?? $slug;
334 if ( $name === $slug ) {
335 $name = wc_attribute_label( $slug );
336 }
337 foreach ( $line['meta_data'] as $meta ) {
338 if ( is_array( $meta ) && isset( $meta['display_key'], $meta['display_value'] )
339 && $meta['display_key'] === $name && $meta['display_value'] ) {
340 $payload['line_items'][ $i ]['meta_data'][] = array(
341 'key' => $slug,
342 'value' => $meta['display_value'],
343 );
344 break;
345 }
346 }
347 }
348 }
349 return $payload;
350 }
351
352 /**
353 * Prefix for a collision-resistant payload-only sku. Stock wc/v3 requires a
354 * product_id OR a sku on line-item create, so a misc line (product_id 0) must
355 * carry one — but a real sku would resolve to a catalog product. The posted
356 * line sku is lookup-only in wc/v3 (never persisted), so the sentinel leaves
357 * no trace on the stored order.
358 */
359 private const MISC_LINE_SKU_SENTINEL = 'wcpos-misc-item-no-sku-lookup';
360
361 /**
362 * Restore the v1 line-item product-identity semantics on the forwarded payload
363 * (issue #1403 row 1). Stock `WC_REST_Orders_V2_Controller::get_product_id`
364 * prefers a posted `sku` over the posted `product_id` and throws when both are
365 * empty on create; v1 overrode it to trust the posted ids (duplicated-sku
366 * catalogs) and to pass misc/custom lines (product_id 0) straight through,
367 * stamping the typed sku as `_sku` item meta (`maybe_set_item_meta_data`).
368 * Express the same at the payload seam:
369 * - a line with a real product/variation id drops its `sku` (ids are authoritative);
370 * - a misc line (product_id === 0, NOT the null-as-delete marker) forwards the
371 * non-colliding sentinel sku and carries its typed sku as `_sku` meta, which
372 * the synthetic-product read path (Orders::order_item_product) serves back.
373 *
374 * @param array $line_items Posted line items.
375 *
376 * @return array The normalized line items.
377 */
378 private function normalize_line_item_product_identity( array $line_items ): array {
379 foreach ( $line_items as $i => $line ) {
380 if ( ! is_array( $line ) ) {
381 continue;
382 }
383 $product_id = array_key_exists( 'product_id', $line ) ? $line['product_id'] : null;
384 $is_misc = null !== $product_id && is_numeric( $product_id ) && 0.0 === (float) $product_id;
385 if ( ! $is_misc ) {
386 // v1 stripped the sku from EVERY non-misc line shape — partial update
387 // lines without a product_id included: the posted ids (or, for partial
388 // updates, the stored line) are authoritative, never a sku lookup. A
389 // non-numeric product_id also lands here, so wc/v3's own schema
390 // validation rejects it instead of a sku silently rebinding the line.
391 unset( $line_items[ $i ]['sku'] );
392 continue;
393 }
394 // Misc/custom product line (product_id exactly 0 — a null product_id is
395 // wc/v3's remove-this-line marker and was excluded above). Mirror v1's
396 // maybe_set_item_meta_data: when the line carries a sku key, its typed
397 // value (including '' — an explicit clear) becomes the single `_sku`
398 // item meta, replacing any stale `_sku` in a pulled order document.
399 if ( array_key_exists( 'sku', $line ) ) {
400 if ( ! is_string( $line['sku'] ) ) {
401 continue;
402 }
403 if ( ! array_key_exists( 'meta_data', $line ) || is_array( $line['meta_data'] ) ) {
404 $typed_sku = trim( $line['sku'] );
405 $meta = $line['meta_data'] ?? array();
406 $has_sku_meta = false;
407 foreach ( $meta as $j => $entry ) {
408 if ( is_array( $entry ) && '_sku' === Meta_Entry::key( $entry ) ) {
409 $meta[ $j ]['value'] = $typed_sku;
410 $has_sku_meta = true;
411 }
412 }
413 if ( ! $has_sku_meta ) {
414 $meta[] = array(
415 'key' => '_sku',
416 'value' => $typed_sku,
417 );
418 }
419 $line_items[ $i ]['meta_data'] = $meta;
420 }
421 }
422 $line_items[ $i ]['sku'] = $this->misc_line_sentinel_sku();
423 }
424
425 return $line_items;
426 }
427
428 /**
429 * A fresh sentinel sku for the forwarded line. A catalog miss probe cannot
430 * make the later wc/v3 lookup atomic, so use a UUID suffix that makes an
431 * independently assigned catalog collision negligibly likely.
432 *
433 * @return string
434 */
435 private function misc_line_sentinel_sku(): string {
436 return self::MISC_LINE_SKU_SENTINEL . '-' . wp_generate_uuid4();
437 }
438
439 /**
440 * Restore missing order item ids from each line type's stable POS UUID.
441 *
442 * Ambiguous or absent UUID matches deliberately remain creates in wc/v3.
443 *
444 * @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve.
445 * @param array $payload Update payload about to be forwarded.
446 * @return array Reconciled payload.
447 */
448 private function reconcile_order_item_ids( $order, array $payload ): array {
449 if ( ! $order ) {
450 return $payload;
451 }
452
453 $types = array(
454 'line_items' => 'line_item',
455 'fee_lines' => 'fee',
456 'shipping_lines' => 'shipping',
457 );
458 foreach ( $types as $payload_key => $item_type ) {
459 if ( ! isset( $payload[ $payload_key ] ) || ! is_array( $payload[ $payload_key ] ) ) {
460 continue;
461 }
462 $matches = array();
463 foreach ( $order->get_items( $item_type ) as $item ) {
464 $uuid = $item->get_meta( Pos_Uuid::META_KEY, true );
465 if ( is_string( $uuid ) && '' !== $uuid ) {
466 $matches[ $uuid ][] = $item->get_id();
467 }
468 }
469 foreach ( $payload[ $payload_key ] as $index => $line ) {
470 if ( ! is_array( $line ) || ! empty( $line['id'] ) || ! is_array( $line['meta_data'] ?? null ) ) {
471 continue;
472 }
473 foreach ( $line['meta_data'] as $meta ) {
474 if ( is_array( $meta ) && Pos_Uuid::META_KEY === Meta_Entry::key( $meta ) && is_string( Meta_Entry::value( $meta ) ) ) {
475 $uuid = Meta_Entry::value( $meta );
476 if ( 1 === count( $matches[ $uuid ] ?? array() ) ) {
477 $payload[ $payload_key ][ $index ]['id'] = $matches[ $uuid ][0];
478 }
479 break;
480 }
481 }
482 }
483 }
484
485 return $payload;
486 }
487
488 /**
489 * Drop the redundant product identity from update lines whose product binding
490 * is unchanged — the port of V1\Orders_Controller::prepare_line_items' dedupe.
491 *
492 * Stock `WC_REST_Orders_V2_Controller::prepare_line_items` compares products by
493 * OBJECT identity (`$product !== $item->get_product()`), which is always true, so
494 * every posted line re-runs `WC_Order_Item_Product::set_product()`. For EVERY
495 * product that copies the catalog's current `name` and `tax_class` onto the stored
496 * item, so an id-only quantity edit of a renamed line would silently reset the
497 * name the merchant sees on the order (posted `name`/`tax_class` are re-applied
498 * afterwards, but an id-only edit posts neither). For a variation it further
499 * calls `set_variation()` → `add_meta_data( 'pa_size', …, true )`, which NULLs
500 * the stored attribute row (marking it for deletion) and appends a fresh, id-less
501 * copy. `maybe_set_item_meta_data()` then runs `update_meta_data( 'pa_size', …, <id> )`
502 * for the posted meta entry, which finds the nulled row BY ID and restores its value —
503 * cancelling the delete while the appended copy is still inserted. Net effect: a
504 * full-document re-push of an acknowledged variation order grows one duplicate
505 * `pa_*` meta row per push (#1456).
506 *
507 * v1 previously pruned duplicates after the fact. At the shared payload seam the
508 * cause is cheaper to remove: when the posted line already resolves to the SAME
509 * product and variation the stored item is bound to, the product binding is a
510 * no-op, so drop `product_id`/`variation_id` from the forwarded line.
511 * `get_product_id()` then returns 0 on update, `wc_get_product( 0 )` is false and
512 * the whole `set_product()` branch is skipped — the stored name, tax class and
513 * attribute rows are updated in place, ids and all, so the acknowledgement is
514 * byte-stable across re-pushes. Lines that genuinely re-bind to a different
515 * product or variation still forward their identity and take WC's normal path.
516 * A simple line's binding is (product_id, 0); a hydrated id-only line always
517 * carries both keys, so it always qualifies.
518 *
519 * @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve.
520 * @param array $payload Reconciled update payload.
521 * @return array Payload with no-op product identity removed from unchanged lines.
522 */
523 private function drop_unchanged_line_identity( $order, array $payload ): array {
524 if ( ! isset( $payload['line_items'] ) || ! is_array( $payload['line_items'] ) ) {
525 return $payload;
526 }
527 if ( ! $order ) {
528 return $payload;
529 }
530 foreach ( $payload['line_items'] as $i => $line ) {
531 if ( ! is_array( $line ) || empty( $line['id'] ) || ! is_numeric( $line['id'] ) ) {
532 continue;
533 }
534 $item = $order->get_item( (int) $line['id'] );
535 if ( ! $item instanceof WC_Order_Item_Product ) {
536 continue;
537 }
538 // A posted sku wins over the ids in wc/v3's get_product_id(), so a line
539 // carrying one is not an unchanged binding as far as WC is concerned.
540 if ( ! empty( $line['sku'] ) ) {
541 continue;
542 }
543 // wc/v3's remove-this-line marker is `product_id: null` (item_is_null). The
544 // WCPOS client posts it on the FULL settled line, acked variation_id and all, so a
545 // removed variation line looks exactly like an unchanged binding from here.
546 // `isset()` is false for null, so the check below would let it through and
547 // unset the very key wc/v3 removes on — the line survived every save.
548 if ( array_key_exists( 'product_id', $line ) && null === $line['product_id'] ) {
549 continue;
550 }
551 if ( ! isset( $line['variation_id'] ) || ! is_numeric( $line['variation_id'] )
552 || (int) $line['variation_id'] !== $item->get_variation_id() ) {
553 continue;
554 }
555 if ( isset( $line['product_id'] ) && ( ! is_numeric( $line['product_id'] ) || (int) $line['product_id'] !== $item->get_product_id() ) ) {
556 continue;
557 }
558 unset( $payload['line_items'][ $i ]['product_id'], $payload['line_items'][ $i ]['variation_id'] );
559 }
560 return $payload;
561 }
562
563 /**
564 * Add wc/v3 deletion markers for stored items omitted from posted line collections.
565 *
566 * @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve.
567 * @param array $payload Reconciled update payload.
568 * @return array Payload containing deletion markers for omitted items.
569 */
570 private function remove_omitted_order_items( $order, array $payload ): array {
571 if ( ! $order ) {
572 return $payload;
573 }
574 $types = array(
575 'line_items' => array( 'line_item', 'product_id' ),
576 'fee_lines' => array( 'fee', 'name' ),
577 'shipping_lines' => array( 'shipping', 'method_id' ),
578 );
579 foreach ( $types as $payload_key => $type ) {
580 if ( ! array_key_exists( $payload_key, $payload ) || ! is_array( $payload[ $payload_key ] ) ) {
581 continue;
582 }
583 $stored_items = $order->get_items( $type[0] );
584 $posted_ids = array();
585 foreach ( $payload[ $payload_key ] as $line ) {
586 if ( is_array( $line ) && ! empty( $line['id'] ) && is_numeric( $line['id'] ) ) {
587 $posted_ids[] = (int) $line['id'];
588 continue;
589 }
590 $uuid = is_array( $line ) && is_array( $line['meta_data'] ?? null )
591 ? Pos_Uuid::read_valid_uuid_from_meta( $line['meta_data'] )
592 : '';
593 foreach ( $stored_items as $item ) {
594 if ( '' !== $uuid && $uuid === $item->get_meta( Pos_Uuid::META_KEY, true ) ) {
595 $posted_ids[] = $item->get_id();
596 }
597 }
598 }
599 foreach ( $stored_items as $item ) {
600 if ( ! in_array( $item->get_id(), $posted_ids, true ) ) {
601 $payload[ $payload_key ][] = array(
602 'id' => $item->get_id(),
603 $type[1] => null,
604 );
605 }
606 }
607 }
608 return $payload;
609 }
610
611 /**
612 * Reconcile a full-document order update's coupon_lines with the stored order —
613 * the v2 port of V1\Orders_Controller::calculate_coupons (issue #1403 row 3).
614 *
615 * Stock wc/v3 treats coupon_lines as remove-and-reapply and throws
616 * `woocommerce_rest_coupon_item_id_readonly` (400) on any line carrying an `id` —
617 * but the POS always pushes the complete order document, whose coupon_lines carry
618 * the ids from the previous ack, so every update of a couponed order would fail.
619 * Mirror the v1 semantics at the forward seam: when the requested coupon code-set
620 * equals the order's current coupons, drop coupon_lines from the forward entirely
621 * (skip the recalculation, preserving stable coupon line ids — v1 returned false);
622 * when the sets differ, strip the ids and let wc/v3 do its remove-and-reapply.
623 *
624 * @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve.
625 * @param array $payload Update payload about to be forwarded.
626 *
627 * @return array The payload with coupon_lines reconciled.
628 */
629 private function reconcile_order_coupon_lines( $order, array $payload ): array {
630 if ( ! isset( $payload['coupon_lines'] ) || ! is_array( $payload['coupon_lines'] ) ) {
631 return $payload;
632 }
633 if ( ! $order ) {
634 return $payload;
635 }
636
637 $requested_codes = array();
638 $all_lines_valid = true;
639 foreach ( $payload['coupon_lines'] as $line ) {
640 $code = is_array( $line ) ? ( $line['code'] ?? null ) : null;
641 if ( ! is_string( $code ) || '' === trim( $code ) ) {
642 // A malformed line must reach wc/v3 so its canonical "Coupon code is
643 // required" validation fires — skipping here would silently ack it.
644 $all_lines_valid = false;
645 break;
646 }
647 $requested_codes[] = wc_strtolower( wc_format_coupon_code( wc_clean( $code ) ) );
648 }
649 $existing_codes = array_map(
650 static function ( $coupon ) {
651 return wc_strtolower( $coupon->get_code() );
652 },
653 array_values( $order->get_coupons() )
654 );
655 sort( $requested_codes );
656 sort( $existing_codes );
657
658 if ( $all_lines_valid && $requested_codes === $existing_codes ) {
659 unset( $payload['coupon_lines'] );
660 return $payload;
661 }
662
663 foreach ( $payload['coupon_lines'] as $i => $line ) {
664 if ( is_array( $line ) ) {
665 unset( $payload['coupon_lines'][ $i ]['id'] );
666 }
667 }
668 $payload['coupon_lines'] = array_values( $payload['coupon_lines'] );
669
670 return $payload;
671 }
672 }
673