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