PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
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.18, at includes/Sync/Order_Write_Payload.php

585 lines 25.7 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 * The v2 write surface owns two halves: the generic write protocol (envelope,
20 * replay, CAS, checkpoint) and the order-specific payload shaping that makes a
21 * POS order document survive wc/v3's strict schema and its remove-and-reapply
22 * line semantics. This class is the second half, extracted verbatim from
23 * API\V2\Write_Controller so the protocol half stays legible; the shaping rules
24 * themselves are unchanged. Client-date validation and tax-ID persistence are
25 * shared with the v1 lane; the remaining shaping rules are not.
26 *
27 * Lane differences still to be reconciled (V1 = API\V1\Orders_Controller; V2 = API\V2\Writers\Order_Writer):
28 * - Coupons: V1::calculate_coupons vs reconcile_order_coupon_lines: v1 skips empty codes; v2 forwards malformed lines for rejection; v2 reconciles updates only.
29 * - Product identity: V1::get_product_id vs normalize_line_item_product_identity: v1 uses loose zero comparison; v2 requires numeric zero and supplies a misc SKU sentinel.
30 * - Misc SKU: V1::maybe_set_item_meta_data vs normalize_line_item_product_identity: v1 uses isset and the stored product ID; v2 requires posted zero, a string SKU, and trims it.
31 * - Any attributes: V1::maybe_set_item_meta_data vs recover_any_variation_attributes: v1 uses stored identity and updates by meta ID (default ''); v2 uses posted IDs (product default 0) and appends only missing keys.
32 * - Variation dedupe: V1::prepare_line_items vs drop_unchanged_variation_line_identity: v1 prunes duplicate rows after preparation; v2 drops unchanged binding IDs before forwarding.
33 * - Tombstones/omissions: V1 uses WC item deletion; v2 preserves explicit product_id null before identity dedupe and adds deletion markers for omitted items; v1 has no omission pass.
34 * - Item UUIDs: V1 uses WC posted item IDs; reconcile_order_item_ids restores missing IDs from unique UUID matches on v2.
35 * - Billing email: V1::wcpos_validate_billing_email/get_item_schema allow empty values; without_empty_billing_email drops ''/null on v2, whose writer explicitly clears '' on update.
36 * - Display fields: V1::get_item_schema relaxes parent_name; sanitize_order_wc_payload drops null parent_name, image, and display meta fields on v2.
37 * - Client date: V1 create filter reads raw JSON; V2::prepare_create reads the document; both now use validate_client_created_gmt (absent/null/empty means no override).
38 * - Tax IDs: v1 coerces, v2 rejects incomplete entries; V1 refreshes its response after persist_tax_ids; V2::persist uses the same snapshot (absent snapshots on create only; [] clears).
39 * - Audit: V1::wcpos_before_order_object_save/create_item/update_item vs V2 audit phases: v2 also handles reassignment, offline payment assertions, and unpaid provenance updates.
40 * - Reserved stock: V1::save_object uses request params (absent values null); V2::forward_with_reserved_stock uses payload defaults (status/transaction '', paid false); both use around_paid_create.
41 * - Write intent: both lanes declare through Services\Order_Write_Intent (v1 at create_item, v2 at Order_Writer::forward); v1 update and direct wc/v3 rely on the ad-hoc intent from the request.
42 * - HPOS caps: V1 permission overrides retry broad edit/delete order caps; V2 Write_Controller::wcpos_check_permissions remaps read/create and ownership-sensitive edit/delete caps; no payload rule.
43 */
44 final class Order_Write_Payload {
45 /**
46 * Shape a CREATE payload for the wc/v3 forward.
47 *
48 * @param array $payload Order payload about to be forwarded to wc/v3.
49 *
50 * @return array The forwardable payload.
51 */
52 public function for_create( array $payload ): array {
53 return $this->sanitize_order_wc_payload( $payload );
54 }
55
56 /**
57 * Shape an UPDATE payload for the wc/v3 forward.
58 *
59 * The step order is load-bearing (see the comment on the last step). The
60 * order is loaded ONCE here and handed to every step that needs it; each
61 * step still no-ops when the id does not resolve, exactly as it did when it
62 * loaded the order itself.
63 *
64 * @param int $order_id Resolved order id.
65 * @param array $payload Update payload about to be forwarded to wc/v3.
66 *
67 * @return array The forwardable payload.
68 */
69 public function for_update( int $order_id, array $payload ): array {
70 $order = wc_get_order( $order_id );
71 if ( ! $order instanceof \WC_Abstract_Order ) {
72 $order = false;
73 }
74 $payload = $this->reconcile_order_item_ids( $order, $payload );
75 $payload = $this->remove_omitted_order_items( $order, $payload );
76 $payload = $this->reconcile_order_coupon_lines( $order, $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_variation_line_identity( $order, $payload );
81 }
82
83 /**
84 * Validate the client creation time; bare GMT values are UTC, not store time.
85 *
86 * @param array $payload Original order document (raw JSON on v1).
87 * @return int|null|WP_Error UTC timestamp, null when absent/empty, or a 400 error.
88 */
89 public function validate_client_created_gmt( array $payload ) {
90 if ( ! isset( $payload['date_created_gmt'] ) ) {
91 return null;
92 }
93 if ( ! is_scalar( $payload['date_created_gmt'] ) ) {
94 return $this->invalid_created_gmt();
95 }
96 $value = wc_clean( wp_unslash( (string) $payload['date_created_gmt'] ) );
97 if ( '' === $value ) {
98 return null;
99 }
100 $timestamp = 1 === preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/i', $value )
101 ? rest_parse_date( 'Z' === strtoupper( substr( $value, -1 ) ) ? $value : $value . 'Z', true ) : false;
102 if ( false === $timestamp ) {
103 return $this->invalid_created_gmt();
104 }
105 return $timestamp > time() + DAY_IN_SECONDS
106 ? 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 ) )
107 : $timestamp;
108 }
109
110 /** Build the stable invalid create timestamp error. */
111 private function invalid_created_gmt(): WP_Error {
112 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 ) );
113 }
114
115 /**
116 * Persist explicit tax IDs, or snapshot the customer only on create.
117 *
118 * The v1 controller uses the read-back to refresh its already-built response.
119 *
120 * @param int $id Saved order ID.
121 * @param array $payload Original order document; an empty tax_ids array clears IDs.
122 * @param bool $is_create Whether to snapshot when tax_ids is absent.
123 * @return array|null Stored tax IDs, or null when the order does not exist.
124 */
125 public function persist_tax_ids( int $id, array $payload, bool $is_create ): ?array {
126 $order = wc_get_order( $id );
127 if ( ! $order ) {
128 return null;
129 }
130 if ( is_array( $payload['tax_ids'] ?? null ) ) {
131 ( new Tax_Id_Writer() )->write_for_order( $order, $payload['tax_ids'] );
132 } elseif ( $is_create && $order->get_customer_id() > 0 ) {
133 ( new Tax_Id_Writer() )->snapshot_from_user_to_order( $order, $order->get_customer_id() );
134 }
135 return ( new Tax_Id_Reader() )->read_for_order( $order );
136 }
137
138 /**
139 * WC-strict-schema tolerance for POS order payloads.
140 *
141 * The v1 surface relaxed the wc/v3 order schema for POS realities (walk-in
142 * sales have no email; client line items carry a nullable parent_name that
143 * WC recomputes anyway) by editing the POS controller's schema — see
144 * V1\Orders_Controller::wcpos_get_item_schema(). The v2 write surface
145 * forwards to the STOCK wc/v3 controller, whose strict schema turns those
146 * POS-legit values into rest_invalid_param 400s (a rejected CREATE then
147 * strands the record client-side: every later update 404s). Express the same
148 * tolerance by dropping the values WC would reject:
149 * - billing.email '' / null → dropped (absent means "no email"; '' fails the format check)
150 * - line_items[n].parent_name null → dropped (schema wants string; the server recomputes it)
151 * - meta_data display fields → dropped (WC derives them and ignores them on write)
152 * - line_items[].image → dropped (server-derived display data; acks serialize
153 * image.id as '' for imageless products, which wc/v3's integer schema rejects
154 * when a client re-pushes its full document)
155 *
156 * @param array $payload Order payload about to be forwarded to wc/v3.
157 *
158 * @return array The payload with WC-rejected POS values dropped.
159 */
160 private function sanitize_order_wc_payload( array $payload ): array {
161 $payload = $this->recover_any_variation_attributes( $payload );
162 $payload = $this->without_empty_billing_email( $payload );
163 if ( isset( $payload['line_items'] ) && is_array( $payload['line_items'] ) ) {
164 foreach ( $payload['line_items'] as $i => $line ) {
165 if ( is_array( $line ) && array_key_exists( 'parent_name', $line ) && null === $line['parent_name'] ) {
166 unset( $payload['line_items'][ $i ]['parent_name'] );
167 }
168 // image is a server-derived display field: acks serialize it with
169 // image.id '' for imageless products, which fails wc/v3's integer
170 // schema when the client re-pushes its full document.
171 if ( is_array( $line ) && array_key_exists( 'image', $line ) ) {
172 unset( $payload['line_items'][ $i ]['image'] );
173 }
174 }
175 $payload['line_items'] = $this->normalize_line_item_product_identity( $payload['line_items'] );
176 }
177 if ( isset( $payload['meta_data'] ) && is_array( $payload['meta_data'] ) ) {
178 foreach ( $payload['meta_data'] as $i => $entry ) {
179 if ( is_array( $entry ) ) {
180 unset( $payload['meta_data'][ $i ]['display_key'], $payload['meta_data'][ $i ]['display_value'] );
181 }
182 }
183 }
184 foreach ( array( 'line_items', 'shipping_lines', 'fee_lines', 'coupon_lines' ) as $line_type ) {
185 if ( ! isset( $payload[ $line_type ] ) || ! is_array( $payload[ $line_type ] ) ) {
186 continue;
187 }
188 foreach ( $payload[ $line_type ] as $i => $line ) {
189 if ( ! is_array( $line ) || ! isset( $line['meta_data'] ) || ! is_array( $line['meta_data'] ) ) {
190 continue;
191 }
192 foreach ( $line['meta_data'] as $j => $entry ) {
193 if ( is_array( $entry ) ) {
194 unset( $payload[ $line_type ][ $i ]['meta_data'][ $j ]['display_key'], $payload[ $line_type ][ $i ]['meta_data'][ $j ]['display_value'] );
195 }
196 }
197 }
198 }
199 return $payload;
200 }
201
202 /**
203 * Drop a billing email value that wc/v3 rejects but POS treats as absent.
204 *
205 * Shared with customer writes so both lanes retain the v1 walk-in rule.
206 *
207 * @param array $payload Payload about to be forwarded to wc/v3.
208 *
209 * @return array Payload with an empty billing email removed.
210 */
211 public function without_empty_billing_email( array $payload ): array {
212 if ( isset( $payload['billing'] ) && is_array( $payload['billing'] )
213 && array_key_exists( 'email', $payload['billing'] )
214 && ( '' === $payload['billing']['email'] || null === $payload['billing']['email'] ) ) {
215 unset( $payload['billing']['email'] );
216 }
217 return $payload;
218 }
219
220 /**
221 * Recover choices for variation attributes whose catalog value is "any".
222 *
223 * @param array $payload Order payload about to be forwarded to wc/v3.
224 * @return array Payload with recoverable real attribute meta appended.
225 */
226 private function recover_any_variation_attributes( array $payload ): array {
227 if ( empty( $payload['line_items'] ) || ! is_array( $payload['line_items'] ) ) {
228 return $payload;
229 }
230 foreach ( $payload['line_items'] as $i => $line ) {
231 if ( empty( $line['variation_id'] ) || empty( $line['meta_data'] ) || ! is_array( $line['meta_data'] ) ) {
232 continue;
233 }
234 $product = wc_get_product( $line['product_id'] ?? 0 );
235 if ( ! $product ) {
236 continue;
237 }
238 $parent_attributes = $product->get_attributes();
239 foreach ( wc_get_product_variation_attributes( $line['variation_id'] ) as $key => $value ) {
240 if ( '' !== $value ) {
241 continue;
242 }
243 $slug = str_replace( 'attribute_', '', $key );
244 if ( ! isset( $parent_attributes[ $slug ] ) ) {
245 continue;
246 }
247 foreach ( $line['meta_data'] as $meta ) {
248 if ( is_array( $meta ) && isset( $meta['key'] ) && $slug === $meta['key'] ) {
249 continue 2;
250 }
251 }
252 $name = $parent_attributes[ $slug ]['name'] ?? $slug;
253 if ( $name === $slug ) {
254 $name = wc_attribute_label( $slug );
255 }
256 foreach ( $line['meta_data'] as $meta ) {
257 if ( is_array( $meta ) && isset( $meta['display_key'], $meta['display_value'] )
258 && $meta['display_key'] === $name && $meta['display_value'] ) {
259 $payload['line_items'][ $i ]['meta_data'][] = array(
260 'key' => $slug,
261 'value' => $meta['display_value'],
262 );
263 break;
264 }
265 }
266 }
267 }
268 return $payload;
269 }
270
271 /**
272 * Prefix for a collision-resistant payload-only sku. Stock wc/v3 requires a
273 * product_id OR a sku on line-item create, so a misc line (product_id 0) must
274 * carry one — but a real sku would resolve to a catalog product. The posted
275 * line sku is lookup-only in wc/v3 (never persisted), so the sentinel leaves
276 * no trace on the stored order.
277 */
278 private const MISC_LINE_SKU_SENTINEL = 'wcpos-misc-item-no-sku-lookup';
279
280 /**
281 * Restore the v1 line-item product-identity semantics on the forwarded payload
282 * (issue #1403 row 1). Stock `WC_REST_Orders_V2_Controller::get_product_id`
283 * prefers a posted `sku` over the posted `product_id` and throws when both are
284 * empty on create; v1 overrode it to trust the posted ids (duplicated-sku
285 * catalogs) and to pass misc/custom lines (product_id 0) straight through,
286 * stamping the typed sku as `_sku` item meta (`maybe_set_item_meta_data`).
287 * Express the same at the payload seam:
288 * - a line with a real product/variation id drops its `sku` (ids are authoritative);
289 * - a misc line (product_id === 0, NOT the null-as-delete marker) forwards the
290 * non-colliding sentinel sku and carries its typed sku as `_sku` meta, which
291 * the synthetic-product read path (Orders::order_item_product) serves back.
292 *
293 * @param array $line_items Posted line items.
294 *
295 * @return array The normalized line items.
296 */
297 private function normalize_line_item_product_identity( array $line_items ): array {
298 foreach ( $line_items as $i => $line ) {
299 if ( ! is_array( $line ) ) {
300 continue;
301 }
302 $product_id = array_key_exists( 'product_id', $line ) ? $line['product_id'] : null;
303 $is_misc = null !== $product_id && is_numeric( $product_id ) && 0.0 === (float) $product_id;
304 if ( ! $is_misc ) {
305 // v1 stripped the sku from EVERY non-misc line shape — partial update
306 // lines without a product_id included: the posted ids (or, for partial
307 // updates, the stored line) are authoritative, never a sku lookup. A
308 // non-numeric product_id also lands here, so wc/v3's own schema
309 // validation rejects it instead of a sku silently rebinding the line.
310 unset( $line_items[ $i ]['sku'] );
311 continue;
312 }
313 // Misc/custom product line (product_id exactly 0 — a null product_id is
314 // wc/v3's remove-this-line marker and was excluded above). Mirror v1's
315 // maybe_set_item_meta_data: when the line carries a sku key, its typed
316 // value (including '' — an explicit clear) becomes the single `_sku`
317 // item meta, replacing any stale `_sku` in a pulled order document.
318 if ( array_key_exists( 'sku', $line ) ) {
319 if ( ! is_string( $line['sku'] ) ) {
320 continue;
321 }
322 if ( ! array_key_exists( 'meta_data', $line ) || is_array( $line['meta_data'] ) ) {
323 $typed_sku = trim( $line['sku'] );
324 $meta = $line['meta_data'] ?? array();
325 $has_sku_meta = false;
326 foreach ( $meta as $j => $entry ) {
327 if ( is_array( $entry ) && '_sku' === Meta_Entry::key( $entry ) ) {
328 $meta[ $j ]['value'] = $typed_sku;
329 $has_sku_meta = true;
330 }
331 }
332 if ( ! $has_sku_meta ) {
333 $meta[] = array(
334 'key' => '_sku',
335 'value' => $typed_sku,
336 );
337 }
338 $line_items[ $i ]['meta_data'] = $meta;
339 }
340 }
341 $line_items[ $i ]['sku'] = $this->misc_line_sentinel_sku();
342 }
343
344 return $line_items;
345 }
346
347 /**
348 * A fresh sentinel sku for the forwarded line. A catalog miss probe cannot
349 * make the later wc/v3 lookup atomic, so use a UUID suffix that makes an
350 * independently assigned catalog collision negligibly likely.
351 *
352 * @return string
353 */
354 private function misc_line_sentinel_sku(): string {
355 return self::MISC_LINE_SKU_SENTINEL . '-' . wp_generate_uuid4();
356 }
357
358 /**
359 * Restore missing order item ids from each line type's stable POS UUID.
360 *
361 * Ambiguous or absent UUID matches deliberately remain creates in wc/v3.
362 *
363 * @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve.
364 * @param array $payload Update payload about to be forwarded.
365 * @return array Reconciled payload.
366 */
367 private function reconcile_order_item_ids( $order, array $payload ): array {
368 if ( ! $order ) {
369 return $payload;
370 }
371
372 $types = array(
373 'line_items' => 'line_item',
374 'fee_lines' => 'fee',
375 'shipping_lines' => 'shipping',
376 );
377 foreach ( $types as $payload_key => $item_type ) {
378 if ( ! isset( $payload[ $payload_key ] ) || ! is_array( $payload[ $payload_key ] ) ) {
379 continue;
380 }
381 $matches = array();
382 foreach ( $order->get_items( $item_type ) as $item ) {
383 $uuid = $item->get_meta( Pos_Uuid::META_KEY, true );
384 if ( is_string( $uuid ) && '' !== $uuid ) {
385 $matches[ $uuid ][] = $item->get_id();
386 }
387 }
388 foreach ( $payload[ $payload_key ] as $index => $line ) {
389 if ( ! is_array( $line ) || ! empty( $line['id'] ) || ! is_array( $line['meta_data'] ?? null ) ) {
390 continue;
391 }
392 foreach ( $line['meta_data'] as $meta ) {
393 if ( is_array( $meta ) && Pos_Uuid::META_KEY === Meta_Entry::key( $meta ) && is_string( Meta_Entry::value( $meta ) ) ) {
394 $uuid = Meta_Entry::value( $meta );
395 if ( 1 === count( $matches[ $uuid ] ?? array() ) ) {
396 $payload[ $payload_key ][ $index ]['id'] = $matches[ $uuid ][0];
397 }
398 break;
399 }
400 }
401 }
402 }
403
404 return $payload;
405 }
406
407 /**
408 * Drop the redundant product identity from update lines whose variation binding
409 * is unchanged — the v2 port of V1\Orders_Controller::prepare_line_items' dedupe.
410 *
411 * Stock `WC_REST_Orders_V2_Controller::prepare_line_items` compares products by
412 * OBJECT identity (`$product !== $item->get_product()`), which is always true, so
413 * every posted line re-runs `WC_Order_Item_Product::set_product()`. For a variation
414 * that calls `set_variation()` → `add_meta_data( 'pa_size', …, true )`, which NULLs
415 * the stored attribute row (marking it for deletion) and appends a fresh, id-less
416 * copy. `maybe_set_item_meta_data()` then runs `update_meta_data( 'pa_size', …, <id> )`
417 * for the posted meta entry, which finds the nulled row BY ID and restores its value —
418 * cancelling the delete while the appended copy is still inserted. Net effect: a
419 * full-document re-push of an acknowledged variation order grows one duplicate
420 * `pa_*` meta row per push (#1456).
421 *
422 * v1 fixed this after the fact by pruning the duplicates. At the v2 forward seam the
423 * cause is cheaper to remove: when the posted line already resolves to the SAME
424 * variation the stored item is bound to, the product binding is a no-op, so drop
425 * `product_id`/`variation_id` from the forwarded line. `get_product_id()` then returns
426 * 0 on update, `wc_get_product( 0 )` is false and the whole `set_product()` branch is
427 * skipped — the stored attribute rows are updated in place, ids and all, so the
428 * acknowledgement is byte-stable across re-pushes. Lines that genuinely re-bind to a
429 * different variation still forward their identity and take WC's normal path.
430 *
431 * @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve.
432 * @param array $payload Reconciled update payload.
433 * @return array Payload with no-op variation identity removed from unchanged lines.
434 */
435 private function drop_unchanged_variation_line_identity( $order, array $payload ): array {
436 if ( ! isset( $payload['line_items'] ) || ! is_array( $payload['line_items'] ) ) {
437 return $payload;
438 }
439 if ( ! $order ) {
440 return $payload;
441 }
442 foreach ( $payload['line_items'] as $i => $line ) {
443 if ( ! is_array( $line ) || empty( $line['id'] ) || ! is_numeric( $line['id'] ) ) {
444 continue;
445 }
446 $item = $order->get_item( (int) $line['id'] );
447 if ( ! $item instanceof WC_Order_Item_Product || $item->get_variation_id() <= 0 ) {
448 continue;
449 }
450 // A posted sku wins over the ids in wc/v3's get_product_id(), so a line
451 // carrying one is not an unchanged binding as far as WC is concerned.
452 if ( ! empty( $line['sku'] ) ) {
453 continue;
454 }
455 // wc/v3's remove-this-line marker is `product_id: null` (item_is_null). The
456 // WCPOS client posts it on the FULL settled line, acked variation_id and all, so a
457 // removed variation line looks exactly like an unchanged binding from here.
458 // `isset()` is false for null, so the check below would let it through and
459 // unset the very key wc/v3 removes on — the line survived every save.
460 if ( array_key_exists( 'product_id', $line ) && null === $line['product_id'] ) {
461 continue;
462 }
463 if ( ! isset( $line['variation_id'] ) || ! is_numeric( $line['variation_id'] )
464 || (int) $line['variation_id'] !== $item->get_variation_id() ) {
465 continue;
466 }
467 if ( isset( $line['product_id'] ) && ( ! is_numeric( $line['product_id'] ) || (int) $line['product_id'] !== $item->get_product_id() ) ) {
468 continue;
469 }
470 unset( $payload['line_items'][ $i ]['product_id'], $payload['line_items'][ $i ]['variation_id'] );
471 }
472 return $payload;
473 }
474
475 /**
476 * Add wc/v3 deletion markers for stored items omitted from posted line collections.
477 *
478 * @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve.
479 * @param array $payload Reconciled update payload.
480 * @return array Payload containing deletion markers for omitted items.
481 */
482 private function remove_omitted_order_items( $order, array $payload ): array {
483 if ( ! $order ) {
484 return $payload;
485 }
486 $types = array(
487 'line_items' => array( 'line_item', 'product_id' ),
488 'fee_lines' => array( 'fee', 'name' ),
489 'shipping_lines' => array( 'shipping', 'method_id' ),
490 );
491 foreach ( $types as $payload_key => $type ) {
492 if ( ! array_key_exists( $payload_key, $payload ) || ! is_array( $payload[ $payload_key ] ) ) {
493 continue;
494 }
495 $stored_items = $order->get_items( $type[0] );
496 $posted_ids = array();
497 foreach ( $payload[ $payload_key ] as $line ) {
498 if ( is_array( $line ) && ! empty( $line['id'] ) && is_numeric( $line['id'] ) ) {
499 $posted_ids[] = (int) $line['id'];
500 continue;
501 }
502 $uuid = is_array( $line ) && is_array( $line['meta_data'] ?? null )
503 ? Pos_Uuid::read_valid_uuid_from_meta( $line['meta_data'] )
504 : '';
505 foreach ( $stored_items as $item ) {
506 if ( '' !== $uuid && $uuid === $item->get_meta( Pos_Uuid::META_KEY, true ) ) {
507 $posted_ids[] = $item->get_id();
508 }
509 }
510 }
511 foreach ( $stored_items as $item ) {
512 if ( ! in_array( $item->get_id(), $posted_ids, true ) ) {
513 $payload[ $payload_key ][] = array(
514 'id' => $item->get_id(),
515 $type[1] => null,
516 );
517 }
518 }
519 }
520 return $payload;
521 }
522
523 /**
524 * Reconcile a full-document order update's coupon_lines with the stored order —
525 * the v2 port of V1\Orders_Controller::calculate_coupons (issue #1403 row 3).
526 *
527 * Stock wc/v3 treats coupon_lines as remove-and-reapply and throws
528 * `woocommerce_rest_coupon_item_id_readonly` (400) on any line carrying an `id` —
529 * but the POS always pushes the complete order document, whose coupon_lines carry
530 * the ids from the previous ack, so every update of a couponed order would fail.
531 * Mirror the v1 semantics at the forward seam: when the requested coupon code-set
532 * equals the order's current coupons, drop coupon_lines from the forward entirely
533 * (skip the recalculation, preserving stable coupon line ids — v1 returned false);
534 * when the sets differ, strip the ids and let wc/v3 do its remove-and-reapply.
535 *
536 * @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve.
537 * @param array $payload Update payload about to be forwarded.
538 *
539 * @return array The payload with coupon_lines reconciled.
540 */
541 private function reconcile_order_coupon_lines( $order, array $payload ): array {
542 if ( ! isset( $payload['coupon_lines'] ) || ! is_array( $payload['coupon_lines'] ) ) {
543 return $payload;
544 }
545 if ( ! $order ) {
546 return $payload;
547 }
548
549 $requested_codes = array();
550 $all_lines_valid = true;
551 foreach ( $payload['coupon_lines'] as $line ) {
552 $code = is_array( $line ) ? ( $line['code'] ?? null ) : null;
553 if ( ! is_string( $code ) || '' === trim( $code ) ) {
554 // A malformed line must reach wc/v3 so its canonical "Coupon code is
555 // required" validation fires — skipping here would silently ack it.
556 $all_lines_valid = false;
557 break;
558 }
559 $requested_codes[] = wc_strtolower( wc_format_coupon_code( wc_clean( $code ) ) );
560 }
561 $existing_codes = array_map(
562 static function ( $coupon ) {
563 return wc_strtolower( $coupon->get_code() );
564 },
565 array_values( $order->get_coupons() )
566 );
567 sort( $requested_codes );
568 sort( $existing_codes );
569
570 if ( $all_lines_valid && $requested_codes === $existing_codes ) {
571 unset( $payload['coupon_lines'] );
572 return $payload;
573 }
574
575 foreach ( $payload['coupon_lines'] as $i => $line ) {
576 if ( is_array( $line ) ) {
577 unset( $payload['coupon_lines'][ $i ]['id'] );
578 }
579 }
580 $payload['coupon_lines'] = array_values( $payload['coupon_lines'] );
581
582 return $payload;
583 }
584 }
585