| 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 |
|