| @@ -7,19 +7,38 @@ | ||
| 7 | 7 | |
| 8 | 8 | namespace WCPOS\WooCommercePOS\Sync; |
| 9 | 9 | |
| 10 | 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; | |
| 11 | 14 | |
| 12 | 15 | /** |
| 13 | 16 | * Shapes a POS order document into the body forwarded to the STOCK wc/v3 orders |
| 14 | 17 | * controller. |
| 15 | 18 | * |
| 16 | - * The v2 write surface owns two halves: the generic write protocol (envelope, | |
| 17 | - * replay, CAS, checkpoint) and the order-specific payload shaping that makes a | |
| 18 | - * POS order document survive wc/v3's strict schema and its remove-and-reapply | |
| 19 | - * line semantics. This class is the second half, extracted verbatim from | |
| 20 | - * API\V2\Write_Controller so the protocol half stays legible; the shaping rules | |
| 21 | - * themselves are unchanged. | |
| 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. | |
| 22 | 41 | */ |
| 23 | 42 | final class Order_Write_Payload { |
| 24 | 43 | /** |
| 25 | 44 | * Shape a CREATE payload for the wc/v3 forward. |
| @@ -28,9 +47,9 @@ | ||
| 28 | 47 | * |
| 29 | 48 | * @return array The forwardable payload. |
| 30 | 49 | */ |
| 31 | 50 | public function for_create( array $payload ): array { |
| 32 | - return $this->sanitize_order_wc_payload( $payload ); | |
| 51 | + return $this->sanitize_order_wc_payload( $this->without_empty_billing_email( $payload ) ); | |
| 33 | 52 | } |
| 34 | 53 | |
| 35 | 54 | /** |
| 36 | 55 | * Shape an UPDATE payload for the wc/v3 forward. |
| @@ -50,33 +69,172 @@ | ||
| 50 | 69 | if ( ! $order instanceof \WC_Abstract_Order ) { |
| 51 | 70 | $order = false; |
| 52 | 71 | } |
| 53 | 72 | $payload = $this->reconcile_order_item_ids( $order, $payload ); |
| 73 | + $payload = $this->hydrate_line_identity_from_stored( $order, $payload ); | |
| 54 | 74 | $payload = $this->remove_omitted_order_items( $order, $payload ); |
| 55 | 75 | $payload = $this->reconcile_order_coupon_lines( $order, $payload ); |
| 76 | + $payload = $this->without_empty_billing_email( $payload ); | |
| 56 | 77 | $payload = $this->sanitize_order_wc_payload( $payload ); |
| 57 | 78 | // Runs last: it reads the FORWARDED line shape, after normalize_line_item_product_identity |
| 58 | 79 | // has already resolved the posted sku (which outranks the ids in wc/v3's get_product_id). |
| 59 | - return $this->drop_unchanged_variation_line_identity( $order, $payload ); | |
| 80 | + return $this->drop_unchanged_line_identity( $order, $payload ); | |
| 60 | 81 | } |
| 61 | 82 | |
| 62 | 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 | + /** | |
| 63 | 220 | * WC-strict-schema tolerance for POS order payloads. |
| 64 | 221 | * |
| 65 | 222 | * The v1 surface relaxed the wc/v3 order schema for POS realities (walk-in |
| 66 | 223 | * sales have no email; client line items carry a nullable parent_name that |
| 67 | 224 | * WC recomputes anyway) by editing the POS controller's schema — see |
| 68 | - * V1\Orders_Controller::wcpos_get_item_schema(). The v2 write surface | |
| 225 | + * V1\Orders_Controller::get_item_schema(). The v2 write surface | |
| 69 | 226 | * forwards to the STOCK wc/v3 controller, whose strict schema turns those |
| 70 | 227 | * POS-legit values into rest_invalid_param 400s (a rejected CREATE then |
| 71 | 228 | * strands the record client-side: every later update 404s). Express the same |
| 72 | 229 | * tolerance by dropping the values WC would reject: |
| 73 | - * - billing.email '' / null → dropped (absent means "no email"; '' fails the format check) | |
| 74 | 230 | * - line_items[n].parent_name null → dropped (schema wants string; the server recomputes it) |
| 75 | 231 | * - meta_data display fields → dropped (WC derives them and ignores them on write) |
| 76 | 232 | * - line_items[].image → dropped (server-derived display data; acks serialize |
| 77 | 233 | * image.id as '' for imageless products, which wc/v3's integer schema rejects |
| 78 | 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. | |
| 79 | 237 | * |
| 80 | 238 | * @param array $payload Order payload about to be forwarded to wc/v3. |
| 81 | 239 | * |
| 82 | 240 | * @return array The payload with WC-rejected POS values dropped. |
| @@ -82,9 +240,8 @@ | ||
| 82 | 240 | * @return array The payload with WC-rejected POS values dropped. |
| 83 | 241 | */ |
| 84 | 242 | private function sanitize_order_wc_payload( array $payload ): array { |
| 85 | 243 | $payload = $this->recover_any_variation_attributes( $payload ); |
| 86 | - $payload = $this->without_empty_billing_email( $payload ); | |
| 87 | 244 | if ( isset( $payload['line_items'] ) && is_array( $payload['line_items'] ) ) { |
| 88 | 245 | foreach ( $payload['line_items'] as $i => $line ) { |
| 89 | 246 | if ( is_array( $line ) && array_key_exists( 'parent_name', $line ) && null === $line['parent_name'] ) { |
| 90 | 247 | unset( $payload['line_items'][ $i ]['parent_name'] ); |
| @@ -328,15 +485,19 @@ | ||
| 328 | 485 | return $payload; |
| 329 | 486 | } |
| 330 | 487 | |
| 331 | 488 | /** |
| 332 | - * Drop the redundant product identity from update lines whose variation binding | |
| 333 | - * is unchanged — the v2 port of V1\Orders_Controller::prepare_line_items' dedupe. | |
| 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. | |
| 334 | 491 | * |
| 335 | 492 | * Stock `WC_REST_Orders_V2_Controller::prepare_line_items` compares products by |
| 336 | 493 | * OBJECT identity (`$product !== $item->get_product()`), which is always true, so |
| 337 | - * every posted line re-runs `WC_Order_Item_Product::set_product()`. For a variation | |
| 338 | - * that calls `set_variation()` → `add_meta_data( 'pa_size', …, true )`, which NULLs | |
| 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 | |
| 339 | 500 | * the stored attribute row (marking it for deletion) and appends a fresh, id-less |
| 340 | 501 | * copy. `maybe_set_item_meta_data()` then runs `update_meta_data( 'pa_size', …, <id> )` |
| 341 | 502 | * for the posted meta entry, which finds the nulled row BY ID and restores its value — |
| 342 | 503 | * cancelling the delete while the appended copy is still inserted. Net effect: a |
| @@ -342,22 +503,25 @@ | ||
| 342 | 503 | * cancelling the delete while the appended copy is still inserted. Net effect: a |
| 343 | 504 | * full-document re-push of an acknowledged variation order grows one duplicate |
| 344 | 505 | * `pa_*` meta row per push (#1456). |
| 345 | 506 | * |
| 346 | - * v1 fixed this after the fact by pruning the duplicates. At the v2 forward seam the | |
| 507 | + * v1 previously pruned duplicates after the fact. At the shared payload seam the | |
| 347 | 508 | * cause is cheaper to remove: when the posted line already resolves to the SAME |
| 348 | - * variation the stored item is bound to, the product binding is a no-op, so drop | |
| 349 | - * `product_id`/`variation_id` from the forwarded line. `get_product_id()` then returns | |
| 350 | - * 0 on update, `wc_get_product( 0 )` is false and the whole `set_product()` branch is | |
| 351 | - * skipped — the stored attribute rows are updated in place, ids and all, so the | |
| 352 | - * acknowledgement is byte-stable across re-pushes. Lines that genuinely re-bind to a | |
| 353 | - * different variation still forward their identity and take WC's normal path. | |
| 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. | |
| 354 | 518 | * |
| 355 | 519 | * @param \WC_Abstract_Order|false $order Loaded order, or false when the id does not resolve. |
| 356 | 520 | * @param array $payload Reconciled update payload. |
| 357 | - * @return array Payload with no-op variation identity removed from unchanged lines. | |
| 521 | + * @return array Payload with no-op product identity removed from unchanged lines. | |
| 358 | 522 | */ |
| 359 | - private function drop_unchanged_variation_line_identity( $order, array $payload ): array { | |
| 523 | + private function drop_unchanged_line_identity( $order, array $payload ): array { | |
| 360 | 524 | if ( ! isset( $payload['line_items'] ) || ! is_array( $payload['line_items'] ) ) { |
| 361 | 525 | return $payload; |
| 362 | 526 | } |
| 363 | 527 | if ( ! $order ) { |
| @@ -367,9 +531,9 @@ | ||
| 367 | 531 | if ( ! is_array( $line ) || empty( $line['id'] ) || ! is_numeric( $line['id'] ) ) { |
| 368 | 532 | continue; |
| 369 | 533 | } |
| 370 | 534 | $item = $order->get_item( (int) $line['id'] ); |
| 371 | - if ( ! $item instanceof WC_Order_Item_Product || $item->get_variation_id() <= 0 ) { | |
| 535 | + if ( ! $item instanceof WC_Order_Item_Product ) { | |
| 372 | 536 | continue; |
| 373 | 537 | } |
| 374 | 538 | // A posted sku wins over the ids in wc/v3's get_product_id(), so a line |
| 375 | 539 | // carrying one is not an unchanged binding as far as WC is concerned. |