PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.2
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.2
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_Serializer.php

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

434 lines 18.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS sync store component.
4 *
5 * @package WCPOS\WooCommercePOS\Sync
6 */
7
8 namespace WCPOS\WooCommercePOS\Sync;
9
10 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
11
12 use WC_Order;
13 use WC_REST_Orders_Controller;
14 use WCPOS\WooCommercePOS\Services\Tax_Id_Reader;
15 use WP_REST_Request;
16 final class Order_Serializer {
17 /**
18 * The augmentation set shared by EVERY v2 order lane — pull, proxy, and the
19 * write-ack. Applied in this order, which is also the order the keys land in
20 * the served payload (`tax_ids` then `links` after wc/v3's own fields).
21 */
22 public const V2_AUGMENTATIONS = array( 'tax_ids', 'image_cast', 'item_uuids', 'links' );
23
24 /**
25 * The pull lane's set: the shared v2 augmentations plus the public
26 * `woocommerce_pos_sync_serialized_order` filter. The filter is pull-only —
27 * the proxy lane runs `woocommerce_pos_sync_proxy_response` instead, and the
28 * write path deliberately keeps third-party read filters off its acks.
29 */
30 public const PULL_AUGMENTATIONS = array( 'tax_ids', 'image_cast', 'item_uuids', 'links', 'serialized_filter' );
31
32 /**
33 * THE order-document assembly for the v2 surface: one recipe, one order of
34 * operations, with the augmentation set stated explicitly by each caller.
35 *
36 * Every v2 lane funnels through here so the wire shape cannot drift between
37 * them again (it did: the write-ack lane was left without item uuids or the
38 * image.id cast when the read lanes gained them).
39 *
40 * The v1 lane (`API\V1\Orders_Controller::wcpos_order_response`) is FROZEN and
41 * deliberately NOT routed through this method — it serves a different wire
42 * shape (HAL `_links` via `add_link()`, not a plain `links` key) that Pro and
43 * deployed clients depend on.
44 *
45 * @param \WC_Order|array $source A `WC_Order` to serialize from scratch (pull lane), or an
46 * already-serialized wc/v3 payload (proxy and write-ack lanes).
47 * @param array $augmentations Explicit augmentation list. Recognized values:
48 * `tax_ids`, `image_cast`, `item_uuids`, `links`, `serialized_filter`.
49 * @param null|WP_REST_Request $request Serialization request; required shape for the `WC_Order` source
50 * and passed on to the `serialized_filter`.
51 * @param null|\WC_Order $order The backing order when `$source` is a payload. Resolved from
52 * `$source['id']` when omitted.
53 *
54 * @return array The assembled order document.
55 */
56 public function document( $source, array $augmentations = array(), ?WP_REST_Request $request = null, $order = null ): array {
57 $request = $request instanceof WP_REST_Request ? $request : new WP_REST_Request();
58
59 if ( $source instanceof WC_Order ) {
60 $order = $source;
61 // Matches the POS client's internal precision — v1 forced dp=6 on every order request.
62 $request->set_param( 'dp', '6' );
63 $controller = new WC_REST_Orders_Controller();
64 $response = rest_ensure_response( $controller->prepare_object_for_response( $order, $request ) );
65 $payload = (array) rest_get_server()->response_to_data( $response, false );
66 } else {
67 $payload = (array) $source;
68 if ( ! $order ) {
69 $order = wc_get_order( (int) ( $payload['id'] ?? 0 ) );
70 }
71 }
72
73 // No backing order (a proxied row whose order vanished mid-request): serve
74 // the payload untouched rather than half-augmenting it.
75 if ( ! $order ) {
76 return $payload;
77 }
78
79 if ( in_array( 'tax_ids', $augmentations, true ) ) {
80 $payload['tax_ids'] = ( new Tax_Id_Reader() )->read_for_order( $order );
81 }
82 if ( in_array( 'image_cast', $augmentations, true ) ) {
83 $payload = self::cast_line_item_image_ids( $payload );
84 }
85 if ( in_array( 'item_uuids', $augmentations, true ) ) {
86 $payload = $this->stamp_item_uuids( $payload, $order );
87 }
88 if ( in_array( 'links', $augmentations, true ) ) {
89 $payload = self::add_pos_links( $payload, $order );
90 }
91 if ( in_array( 'serialized_filter', $augmentations, true ) ) {
92 /**
93 * Allows explicit lab inspection without bypassing WooCommerce/WP REST response preparation.
94 * This filter is additive and must not remove WooCommerce REST fields.
95 */
96 $payload = (array) apply_filters( 'woocommerce_pos_sync_serialized_order', $payload, $order, $request );
97 }
98
99 return $payload;
100 }
101
102 public function serialize_order( int $order_id, WP_REST_Request $request ): array {
103 $order = wc_get_order( $order_id );
104 if ( ! $order ) {
105 return array();
106 }
107
108 return $this->document( $order, self::PULL_AUGMENTATIONS, $request );
109 }
110
111 /**
112 * Add the v1-owned order fields missing from stock wc/v3 serialization.
113 *
114 * Retained as the named shorthand for the three payload augmentations (links
115 * excluded); `document()` is the entry point new callers should use.
116 *
117 * @param array $payload Serialized order payload.
118 * @param \WC_Order $order The order backing the payload.
119 */
120 public function augment_order_payload( array $payload, WC_Order $order ): array {
121 return $this->document( $payload, array( 'tax_ids', 'image_cast', 'item_uuids' ), null, $order );
122 }
123
124 /**
125 * Cast every line item's `image.id` to an int.
126 *
127 * WC core's `get_image_id()` returns a string; both the v1 order response and
128 * the v2 assembly serve it typed, and the canonical revision normalizes it the
129 * same way so a bare wc/v3 re-read still hashes equal.
130 *
131 * @param array $payload Serialized order payload.
132 */
133 public static function cast_line_item_image_ids( array $payload ): array {
134 if ( isset( $payload['line_items'] ) && is_array( $payload['line_items'] ) ) {
135 foreach ( $payload['line_items'] as &$line_item ) {
136 if ( isset( $line_item['image']['id'] ) ) {
137 $line_item['image']['id'] = (int) $line_item['image']['id'];
138 }
139 }
140 unset( $line_item );
141 }
142
143 return $payload;
144 }
145
146 /**
147 * Mirror each served line/shipping/fee/coupon item's POS uuid into its
148 * `meta_data`, stamping the order item first when it has none.
149 *
150 * Coupon lines are load-bearing here (v1 stamped ALL item types —
151 * V1/Orders_Controller::wcpos_order_get_items): the client pairs pushed
152 * vs acked lines strictly by uuid with no positional fallback, so a
153 * served coupon line WITHOUT a uuid can never be paired — a server-side
154 * change to a coupon's discount/discount_tax would silently evade the
155 * order-money divergence alarm.
156 *
157 * @param array $payload Serialized order payload.
158 * @param \WC_Order $order The order backing the payload.
159 */
160 private function stamp_item_uuids( array $payload, $order ): array {
161 $item_types = array(
162 'line_items' => 'line_item',
163 'shipping_lines' => 'shipping',
164 'fee_lines' => 'fee',
165 'coupon_lines' => 'coupon',
166 );
167 foreach ( $item_types as $payload_key => $item_type ) {
168 if ( ! isset( $payload[ $payload_key ] ) || ! is_array( $payload[ $payload_key ] ) ) {
169 continue;
170 }
171 $order_items = $order->get_items( $item_type );
172 foreach ( $payload[ $payload_key ] as &$served_item ) {
173 $item_id = (int) ( $served_item['id'] ?? 0 );
174 if ( ! isset( $order_items[ $item_id ] ) ) {
175 continue;
176 }
177 Pos_Uuid::ensure_order_item_uuid( $order_items[ $item_id ] );
178 $uuid = $order_items[ $item_id ]->get_meta( Pos_Uuid::META_KEY, true );
179 if ( Pos_Uuid::is_uuid( $uuid ) ) {
180 $served_item = Pos_Uuid::ensure_in_payload( $served_item, $uuid );
181 }
182 }
183 unset( $served_item );
184 }
185
186 return $payload;
187 }
188
189 /**
190 * Augment a serialized order payload with the POS checkout payment link.
191 *
192 * Uses the WCPOS checkout route, NOT get_checkout_payment_url(): the custom
193 * route exists to avoid checkout-page framing conflicts (X-Frame-Options),
194 * establish the POS checkout context, and honor the force_ssl policy.
195 *
196 * The URL matches the one the frozen v1 lane builds inline in
197 * `API\V1\Orders_Controller::wcpos_order_response()`; v1 attaches it as a HAL
198 * link (`$response->add_link( 'payment', … )`) while the v2 lanes serve it
199 * under a plain top-level `links` key, so the two wire shapes differ even
200 * though the href does not. Existing links entries (e.g. supplied by the proxy
201 * response filter) are preserved; only `payment` is owned by this helper.
202 *
203 * @param array $payload Serialized order payload.
204 * @param \WC_Order $order The order backing the payload.
205 */
206 public static function add_payment_link( array $payload, $order ): array {
207 $pos_payment_url = add_query_arg(
208 array(
209 'pay_for_order' => true,
210 'key' => method_exists( $order, 'get_order_key' ) ? $order->get_order_key() : '',
211 ),
212 wcpos_checkout_url( 'order-pay/' . $order->get_id() )
213 );
214
215 $links = is_array( $payload['links'] ?? null ) ? $payload['links'] : array();
216 $links['payment'] = array( array( 'href' => $pos_payment_url ) );
217 $payload['links'] = $links;
218 return $payload;
219 }
220
221 /**
222 * Augment a serialized order payload with the POS receipt link.
223 *
224 * @param array $payload Serialized order payload.
225 * @param \WC_Order $order The order backing the payload.
226 */
227 public static function add_receipt_link( array $payload, $order ): array {
228 $pos_receipt_url = add_query_arg(
229 array(
230 'key' => method_exists( $order, 'get_order_key' ) ? $order->get_order_key() : '',
231 ),
232 wcpos_checkout_url( 'wcpos-receipt/' . $order->get_id() )
233 );
234
235 $links = is_array( $payload['links'] ?? null ) ? $payload['links'] : array();
236 $links['receipt'] = array( array( 'href' => $pos_receipt_url ) );
237 $payload['links'] = $links;
238 return $payload;
239 }
240
241 /**
242 * Augment a serialized order payload with POS payment and receipt links.
243 *
244 * @param array $payload Serialized order payload.
245 * @param \WC_Order $order The order backing the payload.
246 */
247 public static function add_pos_links( array $payload, $order ): array {
248 $payload = self::add_payment_link( $payload, $order );
249 return self::add_receipt_link( $payload, $order );
250 }
251
252 public function sync_metadata( array $payload, int $order_id, string $source, bool $partial, int $sequence ): array {
253 return array(
254 'order_id' => $order_id,
255 'source' => $source,
256 'partial' => $partial,
257 'sequence' => $sequence,
258 // UNIFIED (#423 step 1): orders hash through THE canonical
259 // Revision::compute like every other collection — identity-strip,
260 // recursive key-sort, excluded volatile fields. Every order site
261 // (pull, stream, skeleton, snapshot, sync-index, push check)
262 // funnels through here or revision_for, so all move atomically.
263 'revision' => self::canonical_revision( $payload ),
264 'generated_at_gmt' => gmdate( 'c' ),
265 );
266 }
267
268 /*
269 * ---------------------------------------------------------------------------
270 * ORDER REVISION RECIPES — a VERSIONED list, newest first.
271 *
272 * Every entry below is a complete hashing recipe for an order payload, and each
273 * one corresponds to a wire shape this plugin has shipped. A deployed client
274 * stores whatever `currentRevision` it was handed at the time, so the write
275 * path's grace comparer (Write_Controller::revision_matches_with_grace) must be
276 * able to recognise ALL of them. NONE of these may be deleted or altered while
277 * the `woocommerce_pos_sync_legacy_revision_grace` option still exists.
278 *
279 * canonical_revision()
280 * CURRENT. Identity-stripped (order meta + item meta), image.id normalized
281 * to int, `tax_ids` and `links` excluded. Defined so that the augmented v2
282 * document and a BARE wc/v3 re-read of the same order hash identically —
283 * which is what lets the pull, proxy and write-ack lanes all serve the
284 * augmented shape while the write path keeps hashing the bare one.
285 *
286 * pre_item_uuid_canonical_revision()
287 * The shape shipped between the read lanes gaining `tax_ids`/links and the
288 * read-time item-uuid stamping + image.id cast: item uuids stripped, but
289 * image.id left as wc/v3's string and `tax_ids` left in the hash.
290 *
291 * pre_augmentation_canonical_revision()
292 * The shape shipped before ANY v2 read augmentation: order identity meta
293 * stripped, nothing else. Item uuids, image.id and `tax_ids` all hashed
294 * as they arrive.
295 *
296 * legacy_revision()
297 * PRE-CUTOVER (#423 step 2): raw wp_json_encode with no ksort and no
298 * excluded-field list. Its comparer branch reserializes the order through
299 * serialize_order() rather than hashing a bare re-read.
300 *
301 * Retirement (#423 step 4) drops the option and the three non-canonical
302 * recipes together, not one at a time.
303 * ---------------------------------------------------------------------------
304 */
305
306 /** THE canonical order revision: identity-stripped, then Revision::compute. */
307 public static function canonical_revision( array $payload ): string {
308 // tax_ids is a read-time decoration (Tax_Id_Reader) that wc/v3's own
309 // serialization never carries — exclude it so pull, proxy, and write-ack
310 // revisions agree with a bare wc/v3 read of the same order.
311 unset( $payload['tax_ids'], $payload['_rxdb_digest'] );
312
313 // HPOS removes these internal fields only after the restored order's save
314 // hooks run. Exclude them so that save and the completed restore hash alike.
315 if ( isset( $payload['meta_data'] ) && is_array( $payload['meta_data'] ) ) {
316 $payload['meta_data'] = array_values(
317 array_filter(
318 $payload['meta_data'],
319 static function ( $entry ): bool {
320 $key = Meta_Entry::key( $entry );
321 return ! in_array( $key, array( '_wp_trash_meta_status', '_wp_trash_meta_time', '_wp_trash_meta_comments_status' ), true );
322 }
323 )
324 );
325 }
326
327 return Revision::compute( self::strip_item_identity_meta( self::strip_identity_meta( $payload ) ) );
328 }
329
330 /** The canonical recipe used before v2 read augmentations were added. */
331 public static function pre_augmentation_canonical_revision( array $payload ): string {
332 return Revision::compute( self::strip_identity_meta( $payload ) );
333 }
334
335 /** The pre-augmentation canonical recipe before read-time item UUID stamping. */
336 public static function pre_item_uuid_canonical_revision( array $payload ): string {
337 return Revision::compute( self::strip_item_identity_meta( self::strip_identity_meta( $payload ), false ) );
338 }
339
340 /**
341 * Canonicalize items in a COPY of the payload before hashing, so revision
342 * sources hashing the BARE wc/v3 form and lanes serving the augmented form
343 * agree on identical state:
344 * - Drop `_woocommerce_pos_uuid` entries from line/shipping/fee item meta —
345 * the item-level twin of strip_identity_meta(). Read-time item stamping
346 * serves a bare {key,value} entry while the NEXT wc/v3 read serializes the
347 * persisted row with id/display_key/display_value; hashing either form
348 * would make the first post-stamp edit a false 409.
349 * - Normalize line_items[].image.id to an int — the augmented read lanes
350 * serve it typed (v1 parity) while bare wc/v3 serves a string.
351 */
352 private static function strip_item_identity_meta( array $payload, bool $normalize_image_ids = true ): array {
353 // coupon_lines joined the uuid-stamped set with the rest (the client pairs
354 // coupons by uuid too); their identity meta must be hash-invisible for the
355 // same reason as every other line type — the augmented document and a bare
356 // wc/v3 re-read of the same order must hash identically. For payloads from
357 // before coupon stamping existed the extra strip is a no-op.
358 foreach ( array( 'line_items', 'shipping_lines', 'fee_lines', 'coupon_lines' ) as $items_key ) {
359 if ( ! isset( $payload[ $items_key ] ) || ! is_array( $payload[ $items_key ] ) ) {
360 continue;
361 }
362 foreach ( $payload[ $items_key ] as $index => $item ) {
363 if ( ! is_array( $item ) ) {
364 continue;
365 }
366 if ( $normalize_image_ids && 'line_items' === $items_key && isset( $item['image']['id'] ) ) {
367 $payload[ $items_key ][ $index ]['image']['id'] = (int) $item['image']['id'];
368 }
369 if ( ! isset( $item['meta_data'] ) || ! is_array( $item['meta_data'] ) ) {
370 continue;
371 }
372 $payload[ $items_key ][ $index ]['meta_data'] = array_values(
373 array_filter(
374 $item['meta_data'],
375 static function ( $entry ): bool {
376 $key = Meta_Entry::key( $entry );
377 return '_woocommerce_pos_uuid' !== $key;
378 }
379 )
380 );
381 }
382 }
383
384 return $payload;
385 }
386
387 /**
388 * The PRE-CUTOVER byte recipe (no ksort, volatiles included) — kept ONLY
389 * for the write path's grace comparer (#423 step 2), so a client whose
390 * stored baseRevision predates the cutover still drains. Deleted at
391 * retirement (step 4) along with the grace option.
392 */
393 public static function legacy_revision( array $payload ): string {
394 // Pre-cutover payloads never contained the read-time `links` augmentation.
395 // The write path's grace comparer reserializes the CURRENT order (links now
396 // injected) and compares against a hash the client computed BEFORE this
397 // deployment — hashing links here would reject every unchanged pre-upgrade
398 // order with a false 409.
399 unset( $payload['links'], $payload['tax_ids'], $payload['_rxdb_digest'] );
400 $payload = self::strip_item_identity_meta( $payload );
401 foreach ( $payload['line_items'] ?? array() as $index => $line_item ) {
402 if ( isset( $line_item['image']['id'] ) ) {
403 $payload['line_items'][ $index ]['image']['id'] = (string) $line_item['image']['id'];
404 }
405 }
406 $source = wp_json_encode( self::strip_identity_meta( $payload ) );
407 return 'sha256:' . hash( 'sha256', false === $source ? '' : $source );
408 }
409
410 /**
411 * Drop `_woocommerce_pos_uuid` from a COPY of the payload before hashing the
412 * revision: a revision reflects CONTENT, not identity. Read-time stamping injects
413 * the uuid, so leaving it in the hash would change the revision the moment an order
414 * is first stamped — the stored pre-stamp revision would then disagree with the
415 * push-side recompute (`revision_for` in the write controller), rejecting the
416 * first edit as a false 409. Never mutates the served payload (PHP arrays pass by value).
417 */
418 private static function strip_identity_meta( array $payload ): array {
419 if ( ! isset( $payload['meta_data'] ) || ! is_array( $payload['meta_data'] ) ) {
420 return $payload;
421 }
422 $payload['meta_data'] = array_values(
423 array_filter(
424 $payload['meta_data'],
425 static function ( $entry ): bool {
426 $key = Meta_Entry::key( $entry );
427 return '_woocommerce_pos_uuid' !== $key;
428 }
429 )
430 );
431 return $payload;
432 }
433 }
434