PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
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 1.9.13 1.9.12 1.9.11 1.9.10 1.9.9 All 158 releases
woocommerce-pos / includes / Sync / Order_Serializer.php

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

356 lines 14.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 /*
253 * canonical_revision() is THE single order revision recipe.
254 * The pre-1.10.0 versioned recipe list and grace comparer were retired per
255 * docs/adr/0033 (free#1745).
256 */
257
258 /** THE canonical order revision: identity-stripped, then Revision::compute. */
259 public static function canonical_revision( array $payload ): string {
260 // tax_ids is a read-time decoration (Tax_Id_Reader) that wc/v3's own
261 // serialization never carries — exclude it so pull, proxy, and write-ack
262 // revisions agree with a bare wc/v3 read of the same order.
263 unset( $payload['tax_ids'], $payload['_rxdb_digest'] );
264
265 // HPOS removes these internal fields only after the restored order's save
266 // hooks run. The exclusion predates compute-at-pull (ADR 0033) and is frozen
267 // with the 1.10.x recipe: legacy journal rows stored hashes computed with it,
268 // and every read-time hash site (pull fallback, CAS re-read, proxy stamp)
269 // must keep matching them and each other across restore states.
270 if ( isset( $payload['meta_data'] ) && is_array( $payload['meta_data'] ) ) {
271 $payload['meta_data'] = array_values(
272 array_filter(
273 $payload['meta_data'],
274 static function ( $entry ): bool {
275 $key = Meta_Entry::key( $entry );
276 return ! in_array( $key, array( '_wp_trash_meta_status', '_wp_trash_meta_time', '_wp_trash_meta_comments_status' ), true );
277 }
278 )
279 );
280 }
281
282 return Revision::compute( self::strip_item_identity_meta( self::strip_identity_meta( $payload ) ) );
283 }
284
285 /**
286 * Canonicalize items in a COPY of the payload before hashing, so revision
287 * sources hashing the BARE wc/v3 form and lanes serving the augmented form
288 * agree on identical state:
289 * - Drop `_woocommerce_pos_uuid` entries from line/shipping/fee item meta —
290 * the item-level twin of strip_identity_meta(). Read-time item stamping
291 * serves a bare {key,value} entry while the NEXT wc/v3 read serializes the
292 * persisted row with id/display_key/display_value; hashing either form
293 * would make the first post-stamp edit a false 409.
294 * - Normalize line_items[].image.id to an int — the augmented read lanes
295 * serve it typed (v1 parity) while bare wc/v3 serves a string.
296 */
297 private static function strip_item_identity_meta( array $payload ): array {
298 // coupon_lines joined the uuid-stamped set with the rest (the client pairs
299 // coupons by uuid too); their identity meta must be hash-invisible for the
300 // same reason as every other line type — the augmented document and a bare
301 // wc/v3 re-read of the same order must hash identically. For payloads from
302 // before coupon stamping existed the extra strip is a no-op.
303 foreach ( array( 'line_items', 'shipping_lines', 'fee_lines', 'coupon_lines' ) as $items_key ) {
304 if ( ! isset( $payload[ $items_key ] ) || ! is_array( $payload[ $items_key ] ) ) {
305 continue;
306 }
307 foreach ( $payload[ $items_key ] as $index => $item ) {
308 if ( ! is_array( $item ) ) {
309 continue;
310 }
311 if ( 'line_items' === $items_key && isset( $item['image']['id'] ) ) {
312 $payload[ $items_key ][ $index ]['image']['id'] = (int) $item['image']['id'];
313 }
314 if ( ! isset( $item['meta_data'] ) || ! is_array( $item['meta_data'] ) ) {
315 continue;
316 }
317 $payload[ $items_key ][ $index ]['meta_data'] = array_values(
318 array_filter(
319 $item['meta_data'],
320 static function ( $entry ): bool {
321 $key = Meta_Entry::key( $entry );
322 return '_woocommerce_pos_uuid' !== $key;
323 }
324 )
325 );
326 }
327 }
328
329 return $payload;
330 }
331
332 /**
333 * Drop `_woocommerce_pos_uuid` from a COPY of the payload before hashing the
334 * revision: a revision reflects CONTENT, not identity. Read-time stamping injects
335 * the uuid, so leaving it in the hash would change the revision the moment an order
336 * is first stamped — the stored pre-stamp revision would then disagree with the
337 * push-side recompute (`revision_for` in the write controller), rejecting the
338 * first edit as a false 409. Never mutates the served payload (PHP arrays pass by value).
339 */
340 private static function strip_identity_meta( array $payload ): array {
341 if ( ! isset( $payload['meta_data'] ) || ! is_array( $payload['meta_data'] ) ) {
342 return $payload;
343 }
344 $payload['meta_data'] = array_values(
345 array_filter(
346 $payload['meta_data'],
347 static function ( $entry ): bool {
348 $key = Meta_Entry::key( $entry );
349 return '_woocommerce_pos_uuid' !== $key;
350 }
351 )
352 );
353 return $payload;
354 }
355 }
356