| 1 |
<?php |
| 2 |
/** |
| 3 |
* WCPOS order write payload shaping. |
| 4 |
* |
| 5 |
* @package WCPOS\WooCommercePOS\Sync |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace WCPOS\WooCommercePOS\Sync; |
| 9 |
|
| 10 |
use WC_Order_Item_Product; |
| 11 |
use WCPOS\WooCommercePOS\Services\Tax_Id_Reader; |
| 12 |
use WCPOS\WooCommercePOS\Services\Tax_Id_Writer; |
| 13 |
use WP_Error; |
| 14 |
|
| 15 |
/** |
| 16 |
* Shapes a POS order document into the body forwarded to the STOCK wc/v3 orders |
| 17 |
* controller. |
| 18 |
* |
| 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. |
| 22 |
* |
| 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. |
| 41 |
*/ |
| 42 |
final class Order_Write_Payload { |
| 43 |
/** |
| 44 |
* Shape a CREATE payload for the wc/v3 forward. |
| 45 |
* |
| 46 |
* @param array $payload Order payload about to be forwarded to wc/v3. |
| 47 |
* |
| 48 |
* @return array The forwardable payload. |
| 49 |
*/ |
| 50 |
public function for_create( array $payload ): array { |
| 51 |
return $this->sanitize_order_wc_payload( $this->without_empty_billing_email( $payload ) ); |
| 52 |
} |
| 53 |
|
| 54 |
/** |
| 55 |
* Shape an UPDATE payload for the wc/v3 forward. |
| 56 |
* |
| 57 |
* The step order is load-bearing (see the comment on the last step). The |
| 58 |
* order is loaded ONCE here and handed to every step that needs it; each |
| 59 |
* step still no-ops when the id does not resolve, exactly as it did when it |
| 60 |
* loaded the order itself. |
| 61 |
* |
| 62 |
* @param int $order_id Resolved order id. |
| 63 |
* @param array $payload Update payload about to be forwarded to wc/v3. |
| 64 |
* |
| 65 |
* @return array The forwardable payload. |
| 66 |
*/ |
| 67 |
public function for_update( int $order_id, array $payload ): array { |
| 68 |
$order = wc_get_order( $order_id ); |
| 69 |
if ( ! $order instanceof \WC_Abstract_Order ) { |
| 70 |
$order = false; |
| 71 |
} |
| 72 |
$payload = $this->reconcile_order_item_ids( $order, $payload ); |
| 73 |
$payload = $this->hydrate_line_identity_from_stored( $order, $payload ); |
| 74 |
$payload = $this->remove_omitted_order_items( $order, $payload ); |
| 75 |
$payload = $this->reconcile_order_coupon_lines( $order, $payload ); |
| 76 |
$payload = $this->without_empty_billing_email( $payload ); |
| 77 |
$payload = $this->sanitize_order_wc_payload( $payload ); |
| 78 |
// Runs last: it reads the FORWARDED line shape, after normalize_line_item_product_identity |
| 79 |
// has already resolved the posted sku (which outranks the ids in wc/v3's get_product_id). |
| 80 |
return $this->drop_unchanged_line_identity( $order, $payload ); |
| 81 |
} |
| 82 |
|
| 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 |
/** |
| 165 |
* Validate the client creation time; bare GMT values are UTC, not store time. |
| 166 |
* |
| 167 |
* @param array $payload Original order document (raw JSON on v1). |
| 168 |
* @return int|null|WP_Error UTC timestamp, null when absent/empty, or a 400 error. |
| 169 |
*/ |
| 170 |
public function validate_client_created_gmt( array $payload ) { |
| 171 |
if ( ! isset( $payload['date_created_gmt'] ) ) { |
| 172 |
return null; |
| 173 |
} |
| 174 |
if ( ! is_scalar( $payload['date_created_gmt'] ) ) { |
| 175 |
return $this->invalid_created_gmt(); |
| 176 |
} |
| 177 |
$value = wc_clean( wp_unslash( (string) $payload['date_created_gmt'] ) ); |
| 178 |
if ( '' === $value ) { |
| 179 |
return null; |
| 180 |
} |
| 181 |
$timestamp = 1 === preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/i', $value ) |
| 182 |
? rest_parse_date( 'Z' === strtoupper( substr( $value, -1 ) ) ? $value : $value . 'Z', true ) : false; |
| 183 |
if ( false === $timestamp ) { |
| 184 |
return $this->invalid_created_gmt(); |
| 185 |
} |
| 186 |
return $timestamp > time() + DAY_IN_SECONDS |
| 187 |
? new WP_Error( 'woocommerce_pos_rest_future_date_created_gmt', __( 'date_created_gmt cannot be more than 24 hours in the future.', 'woocommerce-pos' ), array( 'status' => 400 ) ) |
| 188 |
: $timestamp; |
| 189 |
} |
| 190 |
|
| 191 |
/** Build the stable invalid create timestamp error. */ |
| 192 |
private function invalid_created_gmt(): WP_Error { |
| 193 |
return new WP_Error( 'woocommerce_pos_rest_invalid_date_created_gmt', __( 'date_created_gmt must be a valid ISO 8601 UTC date.', 'woocommerce-pos' ), array( 'status' => 400 ) ); |
| 194 |
} |
| 195 |
|
| 196 |
/** |
| 197 |
* Persist explicit tax IDs, or snapshot the customer only on create. |
| 198 |
* |
| 199 |
* The v1 controller uses the read-back to refresh its already-built response. |
| 200 |
* |
| 201 |
* @param int $id Saved order ID. |
| 202 |
* @param array $payload Original order document; an empty tax_ids array clears IDs. |
| 203 |
* @param bool $is_create Whether to snapshot when tax_ids is absent. |
| 204 |
* @return array|null Stored tax IDs, or null when the order does not exist. |
| 205 |
*/ |
| 206 |
public function persist_tax_ids( int $id, array $payload, bool $is_create ): ?array { |
| 207 |
$order = wc_get_order( $id ); |
| 208 |
if ( ! $order ) { |
| 209 |
return null; |
| 210 |
} |
| 211 |
if ( is_array( $payload['tax_ids'] ?? null ) ) { |
| 212 |
( new Tax_Id_Writer() )->write_for_order( $order, $payload['tax_ids'] ); |
| 213 |
} elseif ( $is_create && $order->get_customer_id() > 0 ) { |
| 214 |
( new Tax_Id_Writer() )->snapshot_from_user_to_order( $order, $order->get_customer_id() ); |
| 215 |
} |
| 216 |
return ( new Tax_Id_Reader() )->read_for_order( $order ); |
| 217 |
} |
| 218 |
|
| 219 |
/** |
| 220 |
* WC-strict-schema tolerance for POS order payloads. |
| 221 |
* |
| 222 |
* The v1 surface relaxed the wc/v3 order schema for POS realities (walk-in |
| 223 |
* sales have no email; client line items carry a nullable parent_name that |
| 224 |
* WC recomputes anyway) by editing the POS controller's schema — see |
| 225 |
* V1\Orders_Controller::get_item_schema(). The v2 write surface |
| 226 |
* forwards to the STOCK wc/v3 controller, whose strict schema turns those |
| 227 |
* POS-legit values into rest_invalid_param 400s (a rejected CREATE then |
| 228 |
* strands the record client-side: every later update 404s). Express the same |
| 229 |
* tolerance by dropping the values WC would reject: |
| 230 |
* - line_items[n].parent_name null → dropped (schema wants string; the server recomputes it) |
| 231 |
* - meta_data display fields → dropped (WC derives them and ignores them on write) |
| 232 |
* - line_items[].image → dropped (server-derived display data; acks serialize |
| 233 |
* image.id as '' for imageless products, which wc/v3's integer schema rejects |
| 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. |
| 237 |
* |
| 238 |
* @param array $payload Order payload about to be forwarded to wc/v3. |
| 239 |
* |
| 240 |
* @return array The payload with WC-rejected POS values dropped. |
| 241 |
*/ |
| 242 |
private function sanitize_order_wc_payload( array $payload ): array { |
| 243 |
$payload = $this->recover_any_variation_attributes( $payload ); |
| 244 |
if ( isset( $payload['line_items'] ) && is_array( $payload['line_items'] ) ) { |
| 245 |
foreach ( $payload['line_items'] as $i => $line ) { |
| 246 |
if ( is_array( $line ) && array_key_exists( 'parent_name', $line ) && null === $line['parent_name'] ) { |
| 247 |
unset( $payload['line_items'][ $i ]['parent_name'] ); |
| 248 |
} |
| 249 |
// image is a server-derived display field: acks serialize it with |
| 250 |
// image.id '' for imageless products, which fails wc/v3's integer |
| 251 |
// schema when the client re-pushes its full document. |
| 252 |
if ( is_array( $line ) && array_key_exists( 'image', $line ) ) { |
| 253 |
unset( $payload['line_items'][ $i ]['image'] ); |
| 254 |
} |
| 255 |
} |
| 256 |
$payload['line_items'] = $this->normalize_line_item_product_identity( $payload['line_items'] ); |
| 257 |
} |
| 258 |
if ( isset( $payload['meta_data'] ) && is_array( $payload['meta_data'] ) ) { |
| 259 |
foreach ( $payload['meta_data'] as $i => $entry ) { |
| 260 |
if ( is_array( $entry ) ) { |
| 261 |
unset( $payload['meta_data'][ $i ]['display_key'], $payload['meta_data'][ $i ]['display_value'] ); |
| 262 |
} |
| 263 |
} |
| 264 |
} |
| 265 |
foreach ( array( 'line_items', 'shipping_lines', 'fee_lines', 'coupon_lines' ) as $line_type ) { |
| 266 |
if ( ! isset( $payload[ $line_type ] ) || ! is_array( $payload[ $line_type ] ) ) { |
| 267 |
continue; |
| 268 |
} |
| 269 |
foreach ( $payload[ $line_type ] as $i => $line ) { |
| 270 |
if ( ! is_array( $line ) || ! isset( $line['meta_data'] ) || ! is_array( $line['meta_data'] ) ) { |
| 271 |
continue; |
| 272 |
} |
| 273 |
foreach ( $line['meta_data'] as $j => $entry ) { |
| 274 |
if ( is_array( $entry ) ) { |
| 275 |
unset( $payload[ $line_type ][ $i ]['meta_data'][ $j ]['display_key'], $payload[ $line_type ][ $i ]['meta_data'][ $j ]['display_value'] ); |
| 276 |
} |
| 277 |
} |
| 278 |
} |
| 279 |
} |
| 280 |
return $payload; |
| 281 |
} |
| 282 |
|
| 283 |
/** |
| 284 |
* Drop a billing email value that wc/v3 rejects but POS treats as absent. |
| 285 |
* |
| 286 |
* Shared with customer writes so both lanes retain the v1 walk-in rule. |
| 287 |
* |
| 288 |
* @param array $payload Payload about to be forwarded to wc/v3. |
| 289 |
* |
| 290 |
* @return array Payload with an empty billing email removed. |
| 291 |
*/ |
| 292 |
public function without_empty_billing_email( array $payload ): array { |
| 293 |
if ( isset( $payload['billing'] ) && is_array( $payload['billing'] ) |
| 294 |
&& array_key_exists( 'email', $payload['billing'] ) |
| 295 |
&& ( '' === $payload['billing']['email'] || null === $payload['billing']['email'] ) ) { |
| 296 |
unset( $payload['billing']['email'] ); |
| 297 |
} |
| 298 |
return $payload; |
| 299 |
} |
| 300 |
|
| 301 |
/** |
| 302 |
* Recover choices for variation attributes whose catalog value is "any". |
| 303 |
* |
| 304 |
* @param array $payload Order payload about to be forwarded to wc/v3. |
| 305 |
* @return array Payload with recoverable real attribute meta appended. |
| 306 |
*/ |
| 307 |
private function recover_any_variation_attributes( array $payload ): array { |
| 308 |
if ( empty( $payload['line_items'] ) || ! is_array( $payload['line_items'] ) ) { |
| 309 |
return $payload; |
| 310 |
} |
| 311 |
foreach ( $payload['line_items'] as $i => $line ) { |
| 312 |
if ( empty( $line['variation_id'] ) || empty( $line['meta_data'] ) || ! is_array( $line['meta_data'] ) ) { |
| 313 |
continue; |
| 314 |
} |
| 315 |
$product = wc_get_product( $line['product_id'] ?? 0 ); |
| 316 |
if ( ! $product ) { |
| 317 |
continue; |
| 318 |
} |
| 319 |
$parent_attributes = $product->get_attributes(); |
| 320 |
foreach ( wc_get_product_variation_attributes( $line['variation_id'] ) as $key => $value ) { |
| 321 |
if ( '' !== $value ) { |
| 322 |
continue; |
| 323 |
} |
| 324 |
$slug = str_replace( 'attribute_', '', $key ); |
| 325 |
if ( ! isset( $parent_attributes[ $slug ] ) ) { |
| 326 |
continue; |
| 327 |
} |
| 328 |
foreach ( $line['meta_data'] as $meta ) { |
| 329 |
if ( is_array( $meta ) && isset( $meta['key'] ) && $slug === $meta['key'] ) { |
| 330 |
continue 2; |
| 331 |
} |
| 332 |
} |
| 333 |
$name = $parent_attributes[ $slug ]['name'] ?? $slug; |
| 334 |
if ( $name === $slug ) { |
| 335 |
$name = wc_attribute_label( $slug ); |
| 336 |
} |
| 337 |
foreach ( $line['meta_data'] as $meta ) { |
| 338 |
if ( is_array( $meta ) && isset( $meta['display_key'], $meta['display_value'] ) |
| 339 |
&& $meta['display_key'] === $name && $meta['display_value'] ) { |
| 340 |
$payload['line_items'][ $i ]['meta_data'][] = array( |
| 341 |
'key' => $slug, |
| 342 |
'value' => $meta['display_value'], |
| 343 |
); |
| 344 |
break; |
| 345 |
} |
| 346 |
} |
| 347 |
} |
| 348 |
} |
| 349 |
return $payload; |
| 350 |
} |
| 351 |
|
| 352 |
/** |
| 353 |
* Prefix for a collision-resistant payload-only sku. Stock wc/v3 requires a |
| 354 |
* product_id OR a sku on line-item create, so a misc line (product_id 0) must |
| 355 |
* carry one — but a real sku would resolve to a catalog product. The posted |
| 356 |
* line sku is lookup-only in wc/v3 (never persisted), so the sentinel leaves |
| 357 |
* no trace on the stored order. |
| 358 |
*/ |
| 359 |
private const MISC_LINE_SKU_SENTINEL = 'wcpos-misc-item-no-sku-lookup'; |
| 360 |
|
| 361 |
/** |
| 362 |
* Restore the v1 line-item product-identity semantics on the forwarded payload |
| 363 |
* (issue #1403 row 1). Stock `WC_REST_Orders_V2_Controller::get_product_id` |
| 364 |
* prefers a posted `sku` over the posted `product_id` and throws when both are |
| 365 |
* empty on create; v1 overrode it to trust the posted ids (duplicated-sku |
| 366 |
* catalogs) and to pass misc/custom lines (product_id 0) straight through, |
| 367 |
* stamping the typed sku as `_sku` item meta (`maybe_set_item_meta_data`). |
| 368 |
* Express the same at the payload seam: |
| 369 |
* - a line with a real product/variation id drops its `sku` (ids are authoritative); |
| 370 |
* - a misc line (product_id === 0, NOT the null-as-delete marker) forwards the |
| 371 |
* non-colliding sentinel sku and carries its typed sku as `_sku` meta, which |
| 372 |
* the synthetic-product read path (Orders::order_item_product) serves back. |
| 373 |
* |
| 374 |
* @param array $line_items Posted line items. |
| 375 |
* |
| 376 |
* @return array The normalized line items. |
| 377 |
*/ |
| 378 |
private function normalize_line_item_product_identity( array $line_items ): array { |
| 379 |
foreach ( $line_items as $i => $line ) { |
| 380 |
if ( ! is_array( $line ) ) { |
| 381 |
continue; |
| 382 |
} |
| 383 |
$product_id = array_key_exists( 'product_id', $line ) ? $line['product_id'] : null; |
| 384 |
$is_misc = null !== $product_id && is_numeric( $product_id ) && 0.0 === (float) $product_id; |
| 385 |
if ( ! $is_misc ) { |
| 386 |
// v1 stripped the sku from EVERY non-misc line shape — partial update |
| 387 |
// lines without a product_id included: the posted ids (or, for partial |
| 388 |
// updates, the stored line) are authoritative, never a sku lookup. A |
| 389 |
// non-numeric product_id also lands here, so wc/v3's own schema |
| 390 |
// validation rejects it instead of a sku silently rebinding the line. |
| 391 |
unset( $line_items[ $i ]['sku'] ); |
| 392 |
continue; |
| 393 |
} |
| 394 |
// Misc/custom product line (product_id exactly 0 — a null product_id is |
| 395 |
// wc/v3's remove-this-line marker and was excluded above). Mirror v1's |
| 396 |
// maybe_set_item_meta_data: when the line carries a sku key, its typed |
| 397 |
// value (including '' — an explicit clear) becomes the single `_sku` |
| 398 |
// item meta, replacing any stale `_sku` in a pulled order document. |
| 399 |
if ( array_key_exists( 'sku', $line ) ) { |
| 400 |
if ( ! is_string( $line['sku'] ) ) { |
| 401 |
continue; |
| 402 |
} |
| 403 |
if ( ! array_key_exists( 'meta_data', $line ) || is_array( $line['meta_data'] ) ) { |
| 404 |
$typed_sku = trim( $line['sku'] ); |
| 405 |
$meta = $line['meta_data'] ?? array(); |
| 406 |
$has_sku_meta = false; |
| 407 |
foreach ( $meta as $j => $entry ) { |
| 408 |
if ( is_array( $entry ) && '_sku' === Meta_Entry::key( $entry ) ) { |
| 409 |
$meta[ $j ]['value'] = $typed_sku; |
| 410 |
$has_sku_meta = true; |
| 411 |
} |
| 412 |
} |
| 413 |
if ( ! $has_sku_meta ) { |
| 414 |
$meta[] = array( |
| 415 |
'key' => '_sku', |
| 416 |
'value' => $typed_sku, |
| 417 |
); |
| 418 |
} |
| 419 |
$line_items[ $i ]['meta_data'] = $meta; |
| 420 |
} |
| 421 |
} |
| 422 |
$line_items[ $i ]['sku'] = $this->misc_line_sentinel_sku(); |
| 423 |
} |
| 424 |
|
| 425 |
return $line_items; |
| 426 |
} |
| 427 |
|
| 428 |
/** |
| 429 |
* A fresh sentinel sku for the forwarded line. A catalog miss probe cannot |
| 430 |
* make the later wc/v3 lookup atomic, so use a UUID suffix that makes an |
| 431 |
* independently assigned catalog collision negligibly likely. |
| 432 |
* |
| 433 |
* @return string |
| 434 |
*/ |
| 435 |
private function misc_line_sentinel_sku(): string { |
| 436 |
return self::MISC_LINE_SKU_SENTINEL . '-' . wp_generate_uuid4(); |
| 437 |
} |
| 438 |
|
| 439 |
/** |
| 440 |
* Restore missing order item ids from each line type's stable POS UUID. |
| 441 |
* |
| 442 |
* Ambiguous or absent UUID matches deliberately remain creates in wc/v3. |
| 443 |
* |
| 444 |
* @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve. |
| 445 |
* @param array $payload Update payload about to be forwarded. |
| 446 |
* @return array Reconciled payload. |
| 447 |
*/ |
| 448 |
private function reconcile_order_item_ids( $order, array $payload ): array { |
| 449 |
if ( ! $order ) { |
| 450 |
return $payload; |
| 451 |
} |
| 452 |
|
| 453 |
$types = array( |
| 454 |
'line_items' => 'line_item', |
| 455 |
'fee_lines' => 'fee', |
| 456 |
'shipping_lines' => 'shipping', |
| 457 |
); |
| 458 |
foreach ( $types as $payload_key => $item_type ) { |
| 459 |
if ( ! isset( $payload[ $payload_key ] ) || ! is_array( $payload[ $payload_key ] ) ) { |
| 460 |
continue; |
| 461 |
} |
| 462 |
$matches = array(); |
| 463 |
foreach ( $order->get_items( $item_type ) as $item ) { |
| 464 |
$uuid = $item->get_meta( Pos_Uuid::META_KEY, true ); |
| 465 |
if ( is_string( $uuid ) && '' !== $uuid ) { |
| 466 |
$matches[ $uuid ][] = $item->get_id(); |
| 467 |
} |
| 468 |
} |
| 469 |
foreach ( $payload[ $payload_key ] as $index => $line ) { |
| 470 |
if ( ! is_array( $line ) || ! empty( $line['id'] ) || ! is_array( $line['meta_data'] ?? null ) ) { |
| 471 |
continue; |
| 472 |
} |
| 473 |
foreach ( $line['meta_data'] as $meta ) { |
| 474 |
if ( is_array( $meta ) && Pos_Uuid::META_KEY === Meta_Entry::key( $meta ) && is_string( Meta_Entry::value( $meta ) ) ) { |
| 475 |
$uuid = Meta_Entry::value( $meta ); |
| 476 |
if ( 1 === count( $matches[ $uuid ] ?? array() ) ) { |
| 477 |
$payload[ $payload_key ][ $index ]['id'] = $matches[ $uuid ][0]; |
| 478 |
} |
| 479 |
break; |
| 480 |
} |
| 481 |
} |
| 482 |
} |
| 483 |
} |
| 484 |
|
| 485 |
return $payload; |
| 486 |
} |
| 487 |
|
| 488 |
/** |
| 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. |
| 491 |
* |
| 492 |
* Stock `WC_REST_Orders_V2_Controller::prepare_line_items` compares products by |
| 493 |
* OBJECT identity (`$product !== $item->get_product()`), which is always true, so |
| 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 |
| 500 |
* the stored attribute row (marking it for deletion) and appends a fresh, id-less |
| 501 |
* copy. `maybe_set_item_meta_data()` then runs `update_meta_data( 'pa_size', …, <id> )` |
| 502 |
* for the posted meta entry, which finds the nulled row BY ID and restores its value — |
| 503 |
* cancelling the delete while the appended copy is still inserted. Net effect: a |
| 504 |
* full-document re-push of an acknowledged variation order grows one duplicate |
| 505 |
* `pa_*` meta row per push (#1456). |
| 506 |
* |
| 507 |
* v1 previously pruned duplicates after the fact. At the shared payload seam the |
| 508 |
* cause is cheaper to remove: when the posted line already resolves to the SAME |
| 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. |
| 518 |
* |
| 519 |
* @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve. |
| 520 |
* @param array $payload Reconciled update payload. |
| 521 |
* @return array Payload with no-op product identity removed from unchanged lines. |
| 522 |
*/ |
| 523 |
private function drop_unchanged_line_identity( $order, array $payload ): array { |
| 524 |
if ( ! isset( $payload['line_items'] ) || ! is_array( $payload['line_items'] ) ) { |
| 525 |
return $payload; |
| 526 |
} |
| 527 |
if ( ! $order ) { |
| 528 |
return $payload; |
| 529 |
} |
| 530 |
foreach ( $payload['line_items'] as $i => $line ) { |
| 531 |
if ( ! is_array( $line ) || empty( $line['id'] ) || ! is_numeric( $line['id'] ) ) { |
| 532 |
continue; |
| 533 |
} |
| 534 |
$item = $order->get_item( (int) $line['id'] ); |
| 535 |
if ( ! $item instanceof WC_Order_Item_Product ) { |
| 536 |
continue; |
| 537 |
} |
| 538 |
// A posted sku wins over the ids in wc/v3's get_product_id(), so a line |
| 539 |
// carrying one is not an unchanged binding as far as WC is concerned. |
| 540 |
if ( ! empty( $line['sku'] ) ) { |
| 541 |
continue; |
| 542 |
} |
| 543 |
// wc/v3's remove-this-line marker is `product_id: null` (item_is_null). The |
| 544 |
// WCPOS client posts it on the FULL settled line, acked variation_id and all, so a |
| 545 |
// removed variation line looks exactly like an unchanged binding from here. |
| 546 |
// `isset()` is false for null, so the check below would let it through and |
| 547 |
// unset the very key wc/v3 removes on — the line survived every save. |
| 548 |
if ( array_key_exists( 'product_id', $line ) && null === $line['product_id'] ) { |
| 549 |
continue; |
| 550 |
} |
| 551 |
if ( ! isset( $line['variation_id'] ) || ! is_numeric( $line['variation_id'] ) |
| 552 |
|| (int) $line['variation_id'] !== $item->get_variation_id() ) { |
| 553 |
continue; |
| 554 |
} |
| 555 |
if ( isset( $line['product_id'] ) && ( ! is_numeric( $line['product_id'] ) || (int) $line['product_id'] !== $item->get_product_id() ) ) { |
| 556 |
continue; |
| 557 |
} |
| 558 |
unset( $payload['line_items'][ $i ]['product_id'], $payload['line_items'][ $i ]['variation_id'] ); |
| 559 |
} |
| 560 |
return $payload; |
| 561 |
} |
| 562 |
|
| 563 |
/** |
| 564 |
* Add wc/v3 deletion markers for stored items omitted from posted line collections. |
| 565 |
* |
| 566 |
* @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve. |
| 567 |
* @param array $payload Reconciled update payload. |
| 568 |
* @return array Payload containing deletion markers for omitted items. |
| 569 |
*/ |
| 570 |
private function remove_omitted_order_items( $order, array $payload ): array { |
| 571 |
if ( ! $order ) { |
| 572 |
return $payload; |
| 573 |
} |
| 574 |
$types = array( |
| 575 |
'line_items' => array( 'line_item', 'product_id' ), |
| 576 |
'fee_lines' => array( 'fee', 'name' ), |
| 577 |
'shipping_lines' => array( 'shipping', 'method_id' ), |
| 578 |
); |
| 579 |
foreach ( $types as $payload_key => $type ) { |
| 580 |
if ( ! array_key_exists( $payload_key, $payload ) || ! is_array( $payload[ $payload_key ] ) ) { |
| 581 |
continue; |
| 582 |
} |
| 583 |
$stored_items = $order->get_items( $type[0] ); |
| 584 |
$posted_ids = array(); |
| 585 |
foreach ( $payload[ $payload_key ] as $line ) { |
| 586 |
if ( is_array( $line ) && ! empty( $line['id'] ) && is_numeric( $line['id'] ) ) { |
| 587 |
$posted_ids[] = (int) $line['id']; |
| 588 |
continue; |
| 589 |
} |
| 590 |
$uuid = is_array( $line ) && is_array( $line['meta_data'] ?? null ) |
| 591 |
? Pos_Uuid::read_valid_uuid_from_meta( $line['meta_data'] ) |
| 592 |
: ''; |
| 593 |
foreach ( $stored_items as $item ) { |
| 594 |
if ( '' !== $uuid && $uuid === $item->get_meta( Pos_Uuid::META_KEY, true ) ) { |
| 595 |
$posted_ids[] = $item->get_id(); |
| 596 |
} |
| 597 |
} |
| 598 |
} |
| 599 |
foreach ( $stored_items as $item ) { |
| 600 |
if ( ! in_array( $item->get_id(), $posted_ids, true ) ) { |
| 601 |
$payload[ $payload_key ][] = array( |
| 602 |
'id' => $item->get_id(), |
| 603 |
$type[1] => null, |
| 604 |
); |
| 605 |
} |
| 606 |
} |
| 607 |
} |
| 608 |
return $payload; |
| 609 |
} |
| 610 |
|
| 611 |
/** |
| 612 |
* Reconcile a full-document order update's coupon_lines with the stored order — |
| 613 |
* the v2 port of V1\Orders_Controller::calculate_coupons (issue #1403 row 3). |
| 614 |
* |
| 615 |
* Stock wc/v3 treats coupon_lines as remove-and-reapply and throws |
| 616 |
* `woocommerce_rest_coupon_item_id_readonly` (400) on any line carrying an `id` — |
| 617 |
* but the POS always pushes the complete order document, whose coupon_lines carry |
| 618 |
* the ids from the previous ack, so every update of a couponed order would fail. |
| 619 |
* Mirror the v1 semantics at the forward seam: when the requested coupon code-set |
| 620 |
* equals the order's current coupons, drop coupon_lines from the forward entirely |
| 621 |
* (skip the recalculation, preserving stable coupon line ids — v1 returned false); |
| 622 |
* when the sets differ, strip the ids and let wc/v3 do its remove-and-reapply. |
| 623 |
* |
| 624 |
* @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve. |
| 625 |
* @param array $payload Update payload about to be forwarded. |
| 626 |
* |
| 627 |
* @return array The payload with coupon_lines reconciled. |
| 628 |
*/ |
| 629 |
private function reconcile_order_coupon_lines( $order, array $payload ): array { |
| 630 |
if ( ! isset( $payload['coupon_lines'] ) || ! is_array( $payload['coupon_lines'] ) ) { |
| 631 |
return $payload; |
| 632 |
} |
| 633 |
if ( ! $order ) { |
| 634 |
return $payload; |
| 635 |
} |
| 636 |
|
| 637 |
$requested_codes = array(); |
| 638 |
$all_lines_valid = true; |
| 639 |
foreach ( $payload['coupon_lines'] as $line ) { |
| 640 |
$code = is_array( $line ) ? ( $line['code'] ?? null ) : null; |
| 641 |
if ( ! is_string( $code ) || '' === trim( $code ) ) { |
| 642 |
// A malformed line must reach wc/v3 so its canonical "Coupon code is |
| 643 |
// required" validation fires — skipping here would silently ack it. |
| 644 |
$all_lines_valid = false; |
| 645 |
break; |
| 646 |
} |
| 647 |
$requested_codes[] = wc_strtolower( wc_format_coupon_code( wc_clean( $code ) ) ); |
| 648 |
} |
| 649 |
$existing_codes = array_map( |
| 650 |
static function ( $coupon ) { |
| 651 |
return wc_strtolower( $coupon->get_code() ); |
| 652 |
}, |
| 653 |
array_values( $order->get_coupons() ) |
| 654 |
); |
| 655 |
sort( $requested_codes ); |
| 656 |
sort( $existing_codes ); |
| 657 |
|
| 658 |
if ( $all_lines_valid && $requested_codes === $existing_codes ) { |
| 659 |
unset( $payload['coupon_lines'] ); |
| 660 |
return $payload; |
| 661 |
} |
| 662 |
|
| 663 |
foreach ( $payload['coupon_lines'] as $i => $line ) { |
| 664 |
if ( is_array( $line ) ) { |
| 665 |
unset( $payload['coupon_lines'][ $i ]['id'] ); |
| 666 |
} |
| 667 |
} |
| 668 |
$payload['coupon_lines'] = array_values( $payload['coupon_lines'] ); |
| 669 |
|
| 670 |
return $payload; |
| 671 |
} |
| 672 |
} |
| 673 |
|