PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.22
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.22
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 1.10.1 1.10.0 All 167 releases
← All changes | includes/Sync/Order_Write_Payload.php +130 -42 1.10.18 → 1.10.22 View file →
@@ -15,32 +15,30 @@
15 15 /**
16 16 * Shapes a POS order document into the body forwarded to the STOCK wc/v3 orders
17 17 * controller.
18 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.
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.
26 22 *
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.
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.
43 41 */
44 42 final class Order_Write_Payload {
45 43 /**
46 44 * Shape a CREATE payload for the wc/v3 forward.
@@ -49,9 +47,9 @@
49 47 *
50 48 * @return array The forwardable payload.
51 49 */
52 50 public function for_create( array $payload ): array {
53 - return $this->sanitize_order_wc_payload( $payload );
51 + return $this->sanitize_order_wc_payload( $this->without_empty_billing_email( $payload ) );
54 52 }
55 53
56 54 /**
57 55 * Shape an UPDATE payload for the wc/v3 forward.
@@ -71,17 +69,100 @@
71 69 if ( ! $order instanceof \WC_Abstract_Order ) {
72 70 $order = false;
73 71 }
74 72 $payload = $this->reconcile_order_item_ids( $order, $payload );
73 + $payload = $this->hydrate_line_identity_from_stored( $order, $payload );
75 74 $payload = $this->remove_omitted_order_items( $order, $payload );
76 75 $payload = $this->reconcile_order_coupon_lines( $order, $payload );
76 + $payload = $this->without_empty_billing_email( $payload );
77 77 $payload = $this->sanitize_order_wc_payload( $payload );
78 78 // Runs last: it reads the FORWARDED line shape, after normalize_line_item_product_identity
79 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 );
80 + return $this->drop_unchanged_line_identity( $order, $payload );
81 81 }
82 82
83 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 + /**
84 165 * Validate the client creation time; bare GMT values are UTC, not store time.
85 166 *
86 167 * @param array $payload Original order document (raw JSON on v1).
87 168 * @return int|null|WP_Error UTC timestamp, null when absent/empty, or a 400 error.
@@ -140,19 +221,20 @@
140 221 *
141 222 * The v1 surface relaxed the wc/v3 order schema for POS realities (walk-in
142 223 * sales have no email; client line items carry a nullable parent_name that
143 224 * WC recomputes anyway) by editing the POS controller's schema — see
144 - * V1\Orders_Controller::wcpos_get_item_schema(). The v2 write surface
225 + * V1\Orders_Controller::get_item_schema(). The v2 write surface
145 226 * forwards to the STOCK wc/v3 controller, whose strict schema turns those
146 227 * POS-legit values into rest_invalid_param 400s (a rejected CREATE then
147 228 * strands the record client-side: every later update 404s). Express the same
148 229 * tolerance by dropping the values WC would reject:
149 - * - billing.email '' / null → dropped (absent means "no email"; '' fails the format check)
150 230 * - line_items[n].parent_name null → dropped (schema wants string; the server recomputes it)
151 231 * - meta_data display fields → dropped (WC derives them and ignores them on write)
152 232 * - line_items[].image → dropped (server-derived display data; acks serialize
153 233 * image.id as '' for imageless products, which wc/v3's integer schema rejects
154 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.
155 237 *
156 238 * @param array $payload Order payload about to be forwarded to wc/v3.
157 239 *
158 240 * @return array The payload with WC-rejected POS values dropped.
@@ -158,9 +240,8 @@
158 240 * @return array The payload with WC-rejected POS values dropped.
159 241 */
160 242 private function sanitize_order_wc_payload( array $payload ): array {
161 243 $payload = $this->recover_any_variation_attributes( $payload );
162 - $payload = $this->without_empty_billing_email( $payload );
163 244 if ( isset( $payload['line_items'] ) && is_array( $payload['line_items'] ) ) {
164 245 foreach ( $payload['line_items'] as $i => $line ) {
165 246 if ( is_array( $line ) && array_key_exists( 'parent_name', $line ) && null === $line['parent_name'] ) {
166 247 unset( $payload['line_items'][ $i ]['parent_name'] );
@@ -404,15 +485,19 @@
404 485 return $payload;
405 486 }
406 487
407 488 /**
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.
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.
410 491 *
411 492 * Stock `WC_REST_Orders_V2_Controller::prepare_line_items` compares products by
412 493 * 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
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
415 500 * the stored attribute row (marking it for deletion) and appends a fresh, id-less
416 501 * copy. `maybe_set_item_meta_data()` then runs `update_meta_data( 'pa_size', …, <id> )`
417 502 * for the posted meta entry, which finds the nulled row BY ID and restores its value —
418 503 * cancelling the delete while the appended copy is still inserted. Net effect: a
@@ -418,22 +503,25 @@
418 503 * cancelling the delete while the appended copy is still inserted. Net effect: a
419 504 * full-document re-push of an acknowledged variation order grows one duplicate
420 505 * `pa_*` meta row per push (#1456).
421 506 *
422 - * v1 fixed this after the fact by pruning the duplicates. At the v2 forward seam the
507 + * v1 previously pruned duplicates after the fact. At the shared payload seam the
423 508 * 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.
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.
430 518 *
431 519 * @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve.
432 520 * @param array $payload Reconciled update payload.
433 - * @return array Payload with no-op variation identity removed from unchanged lines.
521 + * @return array Payload with no-op product identity removed from unchanged lines.
434 522 */
435 - private function drop_unchanged_variation_line_identity( $order, array $payload ): array {
523 + private function drop_unchanged_line_identity( $order, array $payload ): array {
436 524 if ( ! isset( $payload['line_items'] ) || ! is_array( $payload['line_items'] ) ) {
437 525 return $payload;
438 526 }
439 527 if ( ! $order ) {
@@ -443,9 +531,9 @@
443 531 if ( ! is_array( $line ) || empty( $line['id'] ) || ! is_numeric( $line['id'] ) ) {
444 532 continue;
445 533 }
446 534 $item = $order->get_item( (int) $line['id'] );
447 - if ( ! $item instanceof WC_Order_Item_Product || $item->get_variation_id() <= 0 ) {
535 + if ( ! $item instanceof WC_Order_Item_Product ) {
448 536 continue;
449 537 }
450 538 // A posted sku wins over the ids in wc/v3's get_product_id(), so a line
451 539 // carrying one is not an unchanged binding as far as WC is concerned.