| @@ -9,12 +9,12 @@ | ||
| 9 | 9 | |
| 10 | 10 | namespace WCPOS\WooCommercePOS\API\V2\Writers; |
| 11 | 11 | |
| 12 | 12 | use WCPOS\WooCommercePOS\Services\Order_Notes; |
| 13 | -use WCPOS\WooCommercePOS\Services\Order_Write_Intent; | |
| 14 | 13 | use WCPOS\WooCommercePOS\Services\Pos_Order_Audit; |
| 15 | 14 | use WCPOS\WooCommercePOS\Services\Settings as SettingsService; |
| 16 | 15 | use WCPOS\WooCommercePOS\Services\Stock_Validator; |
| 16 | +use WCPOS\WooCommercePOS\Services\Tax_Id_Writer; | |
| 17 | 17 | use WCPOS\WooCommercePOS\Sync\Meta_Entry; |
| 18 | 18 | use WCPOS\WooCommercePOS\Sync\Order_Serializer; |
| 19 | 19 | use WCPOS\WooCommercePOS\Sync\Order_Write_Payload; |
| 20 | 20 | use WCPOS\WooCommercePOS\Sync\Pos_Uuid; |
| @@ -45,9 +45,9 @@ | ||
| 45 | 45 | } |
| 46 | 46 | |
| 47 | 47 | /** Prepare an order create and its create-only hook policy. */ |
| 48 | 48 | public function prepare_create( array $meta, array $payload, callable $validate_tax_ids ) { |
| 49 | - $created_gmt = $this->order_payload->validate_client_created_gmt( $payload ); | |
| 49 | + $created_gmt = $this->validate_client_created_gmt( $payload ); | |
| 50 | 50 | if ( is_wp_error( $created_gmt ) ) { |
| 51 | 51 | return $created_gmt; |
| 52 | 52 | } |
| 53 | 53 | $error = $validate_tax_ids( $payload ); |
| @@ -119,21 +119,9 @@ | ||
| 119 | 119 | } |
| 120 | 120 | |
| 121 | 121 | /** Forward within the named order hook lifecycle. */ |
| 122 | 122 | public function forward( array $prepared, callable $forward ) { |
| 123 | - $declared = $prepared['context']; | |
| 124 | - if ( ! in_array( $declared['operation'] ?? '', array( 'create', 'update' ), true ) ) { | |
| 125 | - return $this->forward_with_reserved_stock( $prepared, $forward ); | |
| 126 | - } | |
| 127 | - $payload = $prepared['payload']; | |
| 128 | - $declared['requested_status'] = isset( $payload['status'] ) ? (string) $payload['status'] : ''; | |
| 129 | - $declared['set_paid'] = isset( $payload['set_paid'] ) && rest_sanitize_boolean( $payload['set_paid'] ); | |
| 130 | - return Order_Write_Intent::open( | |
| 131 | - $declared, | |
| 132 | - function () use ( $prepared, $forward ) { | |
| 133 | - return $this->forward_with_reserved_stock( $prepared, $forward ); | |
| 134 | - } | |
| 135 | - ); | |
| 123 | + return $this->forward_with_reserved_stock( $prepared, $forward ); | |
| 136 | 124 | } |
| 137 | 125 | |
| 138 | 126 | /** |
| 139 | 127 | * Wrap the create forward in the shared create-pending -> reserve -> complete |
| @@ -188,39 +176,32 @@ | ||
| 188 | 176 | // right, and it is the same order throughout. |
| 189 | 177 | return $response; |
| 190 | 178 | } |
| 191 | 179 | |
| 192 | - /** Persist tax IDs before the identity proof. */ | |
| 193 | - public function after_create( int $id, array $payload ): void { | |
| 194 | - $this->order_payload->persist_tax_ids( $id, $payload, true ); | |
| 195 | - } | |
| 196 | - | |
| 197 | - /** Stamp audit metadata and add the creation note after the identity proof. */ | |
| 198 | - public function after_identity( int $id, array $payload ): void { | |
| 199 | - $this->stamp_order_audit( $id, $payload, true ); | |
| 200 | - $order = wc_get_order( $id ); | |
| 201 | - if ( $order ) { | |
| 202 | - Order_Notes::add_creation_note( $order, get_current_user_id(), $order->get_meta( '_pos_store' ) ); | |
| 203 | - } | |
| 204 | - } | |
| 205 | - | |
| 206 | - /** Restore audit metadata and tax IDs after recovery. */ | |
| 207 | - public function after_recovery( int $id, array $payload ): void { | |
| 208 | - $this->stamp_order_audit( $id, $payload, false ); | |
| 209 | - $this->order_payload->persist_tax_ids( $id, $payload, true ); | |
| 210 | - } | |
| 211 | - | |
| 212 | - /** Persist till metadata, tax IDs, reassignment, and email changes after update. */ | |
| 213 | - public function after_update( int $id, array $payload, array $current, array $response_data, array $context ): void { | |
| 214 | - $this->stamp_order_till_meta( $id, $payload ); | |
| 215 | - $this->order_payload->persist_tax_ids( $id, $payload, false ); | |
| 216 | - $this->persist_cashier_store_reassignment( $id, $current, $response_data, $context ); | |
| 217 | - if ( ! empty( $context['clear_email'] ) ) { | |
| 180 | + /** Persist the order behavior assigned to a controller-owned protocol phase. */ | |
| 181 | + public function persist( string $phase, int $id, array $payload, array $current = array(), array $response_data = array(), array $context = array() ): void { | |
| 182 | + if ( 'create_before_identity' === $phase ) { | |
| 183 | + $this->persist_tax_ids( $id, $payload, true ); | |
| 184 | + } elseif ( 'create_after_identity' === $phase ) { | |
| 185 | + $this->stamp_order_audit( $id, $payload, true ); | |
| 218 | 186 | $order = wc_get_order( $id ); |
| 219 | 187 | if ( $order ) { |
| 220 | - $order->set_billing_email( '' ); | |
| 221 | - $order->get_data_store()->update( $order ); | |
| 188 | + Order_Notes::add_creation_note( $order, get_current_user_id(), $order->get_meta( '_pos_store' ) ); | |
| 222 | 189 | } |
| 190 | + } elseif ( 'create_recovery' === $phase ) { | |
| 191 | + $this->stamp_order_audit( $id, $payload, false ); | |
| 192 | + $this->persist_tax_ids( $id, $payload, true ); | |
| 193 | + } elseif ( 'update' === $phase ) { | |
| 194 | + $this->stamp_order_till_meta( $id, $payload ); | |
| 195 | + $this->persist_tax_ids( $id, $payload, false ); | |
| 196 | + $this->persist_cashier_store_reassignment( $id, $current, $response_data, $context ); | |
| 197 | + if ( ! empty( $context['clear_email'] ) ) { | |
| 198 | + $order = wc_get_order( $id ); | |
| 199 | + if ( $order ) { | |
| 200 | + $order->set_billing_email( '' ); | |
| 201 | + $order->get_data_store()->update( $order ); | |
| 202 | + } | |
| 203 | + } | |
| 223 | 204 | } |
| 224 | 205 | } |
| 225 | 206 | |
| 226 | 207 | /** Execute delete with the named stock restore/rollback lifecycle. */ |
| @@ -256,15 +237,35 @@ | ||
| 256 | 237 | } |
| 257 | 238 | |
| 258 | 239 | /** Apply create/update hook policies around one exact forwarded order. */ |
| 259 | 240 | private function forward_with_order_lifecycle( array $prepared, callable $forward ) { |
| 260 | - $context = $prepared['context']; | |
| 261 | - $created_via = static function ( $order ) { | |
| 262 | - $intent = Order_Write_Intent::current(); | |
| 263 | - if ( $order instanceof \WC_Order && null !== $intent && $intent->is_subject( $order ) && 'woocommerce-pos' !== $order->get_created_via() ) { | |
| 241 | + $context = $prepared['context']; | |
| 242 | + $forwarded_order = null; | |
| 243 | + $pre_insert = static function ( $order, $request, $creating ) use ( $context, &$forwarded_order ) { | |
| 244 | + $is_create = 'create' === $context['operation']; | |
| 245 | + if ( $is_create && $creating && $order instanceof \WC_Order && null === $forwarded_order ) { | |
| 246 | + $forwarded_order = $order; | |
| 247 | + } | |
| 248 | + $target = $is_create ? ( $creating && $order === $forwarded_order ) : ( $order instanceof \WC_Order && $context['id'] === $order->get_id() ); | |
| 249 | + if ( $target ) { | |
| 250 | + foreach ( $context['fill_meta'] as $key => $value ) { | |
| 251 | + $order->update_meta_data( $key, $value ); | |
| 252 | + } | |
| 253 | + } | |
| 254 | + if ( $is_create && $creating && null !== $context['created_gmt'] && $order instanceof \WC_Order ) { | |
| 255 | + $order->set_date_created( $context['created_gmt'] ); | |
| 256 | + } | |
| 257 | + return $order; | |
| 258 | + }; | |
| 259 | + $created_via = static function ( $order ) use ( &$forwarded_order ) { | |
| 260 | + if ( $order instanceof \WC_Order && $order === $forwarded_order && 'woocommerce-pos' !== $order->get_created_via() ) { | |
| 264 | 261 | $order->set_created_via( 'woocommerce-pos' ); |
| 265 | 262 | } |
| 266 | 263 | }; |
| 264 | + $use_filter = 'create' === $context['operation'] || array() !== $context['fill_meta']; | |
| 265 | + if ( $use_filter ) { | |
| 266 | + add_filter( 'woocommerce_rest_pre_insert_shop_order_object', $pre_insert, 10, 3 ); | |
| 267 | + } | |
| 267 | 268 | if ( 'create' === $context['operation'] ) { |
| 268 | 269 | add_action( 'woocommerce_before_order_object_save', $created_via ); |
| 269 | 270 | } |
| 270 | 271 | try { |
| @@ -269,8 +270,11 @@ | ||
| 269 | 270 | } |
| 270 | 271 | try { |
| 271 | 272 | return $forward( $prepared['method'], $prepared['route'], $prepared['payload'] ); |
| 272 | 273 | } finally { |
| 274 | + if ( $use_filter ) { | |
| 275 | + remove_filter( 'woocommerce_rest_pre_insert_shop_order_object', $pre_insert, 10 ); | |
| 276 | + } | |
| 273 | 277 | if ( 'create' === $context['operation'] ) { |
| 274 | 278 | remove_action( 'woocommerce_before_order_object_save', $created_via ); |
| 275 | 279 | } |
| 276 | 280 | } |
| @@ -399,8 +403,21 @@ | ||
| 399 | 403 | Order_Notes::add_pos_customer_change_note( $order, $current['customer_id'], $data['customer_id'] ); |
| 400 | 404 | } |
| 401 | 405 | } |
| 402 | 406 | |
| 407 | + /** Persist order tax IDs or the create-time customer snapshot. */ | |
| 408 | + private function persist_tax_ids( int $id, array $payload, bool $is_create ): void { | |
| 409 | + $order = wc_get_order( $id ); | |
| 410 | + if ( ! $order ) { | |
| 411 | + return; | |
| 412 | + } | |
| 413 | + if ( is_array( $payload['tax_ids'] ?? null ) ) { | |
| 414 | + ( new Tax_Id_Writer() )->write_for_order( $order, $payload['tax_ids'] ); | |
| 415 | + } elseif ( $is_create && $order->get_customer_id() > 0 ) { | |
| 416 | + ( new Tax_Id_Writer() )->snapshot_from_user_to_order( $order, $order->get_customer_id() ); | |
| 417 | + } | |
| 418 | + } | |
| 419 | + | |
| 403 | 420 | /** Persist server-owned order audit metadata. */ |
| 404 | 421 | private function stamp_order_audit( int $id, array $payload, bool $stamp_version ): void { |
| 405 | 422 | $meta = array( '_pos_user' => (string) get_current_user_id() ); |
| 406 | 423 | // Create-shaped phases only (create_after_identity, create_recovery, the |
| @@ -437,8 +454,35 @@ | ||
| 437 | 454 | private function without_pos_audit_meta( array $payload, int $id = 0 ): array { |
| 438 | 455 | $meta = is_array( $payload['meta_data'] ?? null ) ? $payload['meta_data'] : array(); |
| 439 | 456 | $protected = $id > 0 ? Pos_Order_Audit::audit_meta_ids( wc_get_order( $id ) ) : array(); |
| 440 | 457 | return Pos_Order_Audit::strip_audit_meta( $meta, $protected ); |
| 458 | + } | |
| 459 | + | |
| 460 | + /** Validate and normalize the optional client create timestamp. */ | |
| 461 | + private function validate_client_created_gmt( array $payload ) { | |
| 462 | + if ( ! isset( $payload['date_created_gmt'] ) ) { | |
| 463 | + return null; | |
| 464 | + } | |
| 465 | + if ( ! is_scalar( $payload['date_created_gmt'] ) ) { | |
| 466 | + return $this->invalid_created_gmt(); | |
| 467 | + } | |
| 468 | + $value = wc_clean( wp_unslash( (string) $payload['date_created_gmt'] ) ); | |
| 469 | + if ( '' === $value ) { | |
| 470 | + return null; | |
| 471 | + } | |
| 472 | + $timestamp = 1 === preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/i', $value ) | |
| 473 | + ? rest_parse_date( 'Z' === strtoupper( substr( $value, -1 ) ) ? $value : $value . 'Z', true ) : false; | |
| 474 | + if ( false === $timestamp ) { | |
| 475 | + return $this->invalid_created_gmt(); | |
| 476 | + } | |
| 477 | + return $timestamp > time() + DAY_IN_SECONDS | |
| 478 | + ? 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 ) ) | |
| 479 | + : $timestamp; | |
| 480 | + } | |
| 481 | + | |
| 482 | + /** Build the stable invalid create timestamp error. */ | |
| 483 | + private function invalid_created_gmt(): WP_Error { | |
| 484 | + 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 ) ); | |
| 441 | 485 | } |
| 442 | 486 | |
| 443 | 487 | /** Whether order stock was actually reduced before delete. */ |
| 444 | 488 | private function order_stock_reduced( int $id ): bool { |