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.25 1.10.24 1.10.23 1.10.22 1.10.21 1.10.20 1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 All 169 releases
← All changes | includes/Sync/Order_Write_Payload.php +25 -189 1.10.21 → 1.10.17 View file →
@@ -7,38 +7,19 @@
7 7
8 8 namespace WCPOS\WooCommercePOS\Sync;
9 9
10 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 11
15 12 /**
16 13 * Shapes a POS order document into the body forwarded to the STOCK wc/v3 orders
17 14 * controller.
18 15 *
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.
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.
41 22 */
42 23 final class Order_Write_Payload {
43 24 /**
44 25 * Shape a CREATE payload for the wc/v3 forward.
@@ -47,9 +28,9 @@
47 28 *
48 29 * @return array The forwardable payload.
49 30 */
50 31 public function for_create( array $payload ): array {
51 - return $this->sanitize_order_wc_payload( $this->without_empty_billing_email( $payload ) );
32 + return $this->sanitize_order_wc_payload( $payload );
52 33 }
53 34
54 35 /**
55 36 * Shape an UPDATE payload for the wc/v3 forward.
@@ -69,172 +50,33 @@
69 50 if ( ! $order instanceof \WC_Abstract_Order ) {
70 51 $order = false;
71 52 }
72 53 $payload = $this->reconcile_order_item_ids( $order, $payload );
73 - $payload = $this->hydrate_line_identity_from_stored( $order, $payload );
74 54 $payload = $this->remove_omitted_order_items( $order, $payload );
75 55 $payload = $this->reconcile_order_coupon_lines( $order, $payload );
76 - $payload = $this->without_empty_billing_email( $payload );
77 56 $payload = $this->sanitize_order_wc_payload( $payload );
78 57 // Runs last: it reads the FORWARDED line shape, after normalize_line_item_product_identity
79 58 // 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 );
59 + return $this->drop_unchanged_variation_line_identity( $order, $payload );
81 60 }
82 61
83 62 /**
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 63 * WC-strict-schema tolerance for POS order payloads.
221 64 *
222 65 * The v1 surface relaxed the wc/v3 order schema for POS realities (walk-in
223 66 * sales have no email; client line items carry a nullable parent_name that
224 67 * WC recomputes anyway) by editing the POS controller's schema — see
225 - * V1\Orders_Controller::get_item_schema(). The v2 write surface
68 + * V1\Orders_Controller::wcpos_get_item_schema(). The v2 write surface
226 69 * forwards to the STOCK wc/v3 controller, whose strict schema turns those
227 70 * POS-legit values into rest_invalid_param 400s (a rejected CREATE then
228 71 * strands the record client-side: every later update 404s). Express the same
229 72 * tolerance by dropping the values WC would reject:
73 + * - billing.email '' / null → dropped (absent means "no email"; '' fails the format check)
230 74 * - line_items[n].parent_name null → dropped (schema wants string; the server recomputes it)
231 75 * - meta_data display fields → dropped (WC derives them and ignores them on write)
232 76 * - line_items[].image → dropped (server-derived display data; acks serialize
233 77 * image.id as '' for imageless products, which wc/v3's integer schema rejects
234 78 * 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 79 *
238 80 * @param array $payload Order payload about to be forwarded to wc/v3.
239 81 *
240 82 * @return array The payload with WC-rejected POS values dropped.
@@ -240,8 +82,9 @@
240 82 * @return array The payload with WC-rejected POS values dropped.
241 83 */
242 84 private function sanitize_order_wc_payload( array $payload ): array {
243 85 $payload = $this->recover_any_variation_attributes( $payload );
86 + $payload = $this->without_empty_billing_email( $payload );
244 87 if ( isset( $payload['line_items'] ) && is_array( $payload['line_items'] ) ) {
245 88 foreach ( $payload['line_items'] as $i => $line ) {
246 89 if ( is_array( $line ) && array_key_exists( 'parent_name', $line ) && null === $line['parent_name'] ) {
247 90 unset( $payload['line_items'][ $i ]['parent_name'] );
@@ -485,19 +328,15 @@
485 328 return $payload;
486 329 }
487 330
488 331 /**
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.
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.
491 334 *
492 335 * Stock `WC_REST_Orders_V2_Controller::prepare_line_items` compares products by
493 336 * 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
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
500 339 * the stored attribute row (marking it for deletion) and appends a fresh, id-less
501 340 * copy. `maybe_set_item_meta_data()` then runs `update_meta_data( 'pa_size', …, <id> )`
502 341 * for the posted meta entry, which finds the nulled row BY ID and restores its value —
503 342 * cancelling the delete while the appended copy is still inserted. Net effect: a
@@ -503,25 +342,22 @@
503 342 * cancelling the delete while the appended copy is still inserted. Net effect: a
504 343 * full-document re-push of an acknowledged variation order grows one duplicate
505 344 * `pa_*` meta row per push (#1456).
506 345 *
507 - * v1 previously pruned duplicates after the fact. At the shared payload seam the
346 + * v1 fixed this after the fact by pruning the duplicates. At the v2 forward seam the
508 347 * 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.
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.
518 354 *
519 355 * @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve.
520 356 * @param array $payload Reconciled update payload.
521 - * @return array Payload with no-op product identity removed from unchanged lines.
357 + * @return array Payload with no-op variation identity removed from unchanged lines.
522 358 */
523 - private function drop_unchanged_line_identity( $order, array $payload ): array {
359 + private function drop_unchanged_variation_line_identity( $order, array $payload ): array {
524 360 if ( ! isset( $payload['line_items'] ) || ! is_array( $payload['line_items'] ) ) {
525 361 return $payload;
526 362 }
527 363 if ( ! $order ) {
@@ -531,9 +367,9 @@
531 367 if ( ! is_array( $line ) || empty( $line['id'] ) || ! is_numeric( $line['id'] ) ) {
532 368 continue;
533 369 }
534 370 $item = $order->get_item( (int) $line['id'] );
535 - if ( ! $item instanceof WC_Order_Item_Product ) {
371 + if ( ! $item instanceof WC_Order_Item_Product || $item->get_variation_id() <= 0 ) {
536 372 continue;
537 373 }
538 374 // A posted sku wins over the ids in wc/v3's get_product_id(), so a line
539 375 // carrying one is not an unchanged binding as far as WC is concerned.