prepare(). // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter const ORDERPING_CANCEL_PATH = '/wp-json/orderping/v1/cancel'; /** * Action table name. * * @return string */ function orderping_cancel_action_table() { global $wpdb; return $wpdb->prefix . 'orderping_cancel_action'; } /** * Creates the cancel action table (called from orderping_install_tables()). */ function orderping_install_cancel_tables() { global $wpdb; $charset = $wpdb->get_charset_collate(); $table = orderping_cancel_action_table(); dbDelta( "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, order_id bigint(20) unsigned NOT NULL, action_id varchar(100) NOT NULL, result varchar(16) NOT NULL DEFAULT 'claimed', created_at bigint(20) unsigned NOT NULL, PRIMARY KEY (id), UNIQUE KEY order_action (order_id,action_id) ) {$charset};" ); } /** * Classifies a WooCommerce status slug (without the wc- prefix). * * @param string $status Order status. * @return string cancellable|shipped|already_cancelled|not_cancellable */ function orderping_cancel_classify( $status ) { switch ( $status ) { case 'pending': case 'processing': case 'on-hold': return 'cancellable'; case 'completed': return 'shipped'; case 'cancelled': return 'already_cancelled'; default: return 'not_cancellable'; } } /** * Stored result of an action, or null when unknown. * * @param int $order_id Order ID. * @param string $action_id Action ID. * @return string|null */ function orderping_cancel_action_result( $order_id, $action_id ) { global $wpdb; $table = orderping_cancel_action_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $result = $wpdb->get_var( $wpdb->prepare( "SELECT result FROM {$table} WHERE order_id = %d AND action_id = %s", $order_id, $action_id ) ); return is_string( $result ) ? $result : null; } /** * Claims the action with UNIQUE(order_id, action_id), after re-checking the * order's freshness under a row lock. * * @param int $order_id Order ID. * @param string $action_id Action ID. * @param string $freshness Freshness the caller observed. * @return string claimed|order_modified|out_of_scope|repeat|error */ function orderping_cancel_claim( $order_id, $action_id, $freshness ) { global $wpdb; $table = orderping_cancel_action_table(); $suppress = $wpdb->suppress_errors( true ); $wpdb->query( 'START TRANSACTION' ); orderping_lock_order_row( $order_id ); $fresh = orderping_fresh_order( $order_id ); if ( $fresh instanceof WC_Order && ! orderping_order_in_scope( $fresh ) ) { $wpdb->query( 'ROLLBACK' ); $wpdb->suppress_errors( $suppress ); return 'out_of_scope'; } if ( ! $fresh instanceof WC_Order || orderping_order_freshness( $fresh ) !== $freshness ) { $wpdb->query( 'ROLLBACK' ); $wpdb->suppress_errors( $suppress ); return 'order_modified'; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $inserted = $wpdb->query( $wpdb->prepare( "INSERT INTO {$table} (order_id, action_id, created_at) VALUES (%d, %s, %d)", $order_id, $action_id, time() ) ); if ( 1 !== (int) $inserted ) { $wpdb->query( 'ROLLBACK' ); $wpdb->suppress_errors( $suppress ); return null !== orderping_cancel_action_result( $order_id, $action_id ) ? 'repeat' : 'error'; } $committed = $wpdb->query( 'COMMIT' ); $wpdb->suppress_errors( $suppress ); return false === $committed ? 'error' : 'claimed'; } /** * Builds the response body. * * @param WC_Order $order Order. * @param string $status cancelled|already_cancelled|shipped|not_cancellable|not_found|claimed. * @return array */ function orderping_cancel_ok( WC_Order $order, $status ) { return array( 'status' => $status, 'order_status' => $order->get_status(), 'modified' => orderping_order_modified( $order ), 'order' => orderping_order_payload( $order ), ); } /** * Handles a signed cancel write or read-back. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function orderping_rest_cancel( WP_REST_Request $request ) { $conn = orderping_connection(); if ( null === $conn || $conn['id'] !== (string) $request->get_header( 'x_op_connection' ) ) { return new WP_Error( 'orderping_unauthorized', 'Unauthorized', array( 'status' => 401 ) ); } $body = $request->get_body(); $verdict = orderping_verify( $conn['secret'], 'cp-to-site', 'POST', ORDERPING_CANCEL_PATH, (string) $request->get_header( 'x_op_timestamp' ), (string) $request->get_header( 'x_op_nonce' ), (string) $request->get_header( 'x_op_signature' ), $body, time() ); if ( true !== $verdict ) { return new WP_Error( 'orderping_unauthorized', 'Unauthorized', array( 'status' => 401 ) ); } // Claim the nonce only after the signature is valid. if ( ! orderping_claim_nonce( (string) $request->get_header( 'x_op_nonce' ) ) ) { return new WP_Error( 'orderping_replayed', 'Unauthorized', array( 'status' => 401 ) ); } $data = json_decode( $body, true ); if ( ! is_array( $data ) || 1 !== ( $data['contract_version'] ?? null ) || ! in_array( $data['mode'] ?? '', array( 'write', 'read_back' ), true ) || ! is_string( $data['action_id'] ?? null ) || ! preg_match( '/^[A-Za-z0-9:_-]{1,100}$/', $data['action_id'] ) || ! is_string( $data['order_id'] ?? null ) || ! preg_match( '/^\d{1,20}$/', $data['order_id'] ) || ( null !== ( $data['observed_freshness'] ?? null ) && ( ! is_string( $data['observed_freshness'] ) || strlen( $data['observed_freshness'] ) > 100 ) ) ) { return new WP_Error( 'orderping_invalid', 'Invalid request', array( 'status' => 400 ) ); } // Cache-bypassing read: the freshness check must see what is stored. $order = orderping_fresh_order( (int) $data['order_id'] ); // Pilot scope and capture first: nothing about any other order is read, written or returned. if ( ! $order instanceof WC_Order || ! orderping_order_in_scope( $order ) || ! orderping_order_was_captured( $order->get_id() ) ) { return new WP_Error( 'orderping_not_found', 'Order not found', array( 'status' => 404 ) ); } $order_id = $order->get_id(); $action_id = $data['action_id']; $class = orderping_cancel_classify( $order->get_status() ); $known = orderping_cancel_action_result( $order_id, $action_id ); if ( 'read_back' === $data['mode'] ) { if ( null === $known ) { return new WP_REST_Response( orderping_cancel_ok( $order, 'not_found' ), 200 ); } return new WP_REST_Response( orderping_cancel_ok( $order, 'already_cancelled' === $class ? 'cancelled' : 'claimed' ), 200 ); } // A replayed action never repeats the cancel: report where the order stands. if ( 'done' === $known ) { return new WP_REST_Response( orderping_cancel_ok( $order, 'cancelled' ), 200 ); } $freshness = orderping_order_freshness( $order ); // The backend binds the cancel to the order it confirmed: it must have seen these exact fields. if ( ! isset( $data['observed_freshness'] ) || $data['observed_freshness'] !== $freshness ) { return new WP_REST_Response( array( 'error' => 'order_modified', 'modified' => orderping_order_modified( $order ), 'freshness' => $freshness, 'order' => orderping_order_payload( $order ), ), 409 ); } if ( 'already_cancelled' === $class ) { return new WP_REST_Response( orderping_cancel_ok( $order, 'already_cancelled' ), 200 ); } if ( 'shipped' === $class || 'not_cancellable' === $class ) { return new WP_REST_Response( orderping_cancel_ok( $order, $class ), 200 ); } $newly_claimed = false; if ( null === $known ) { $claim = orderping_cancel_claim( $order_id, $action_id, $freshness ); if ( 'out_of_scope' === $claim ) { return new WP_Error( 'orderping_not_found', 'Order not found', array( 'status' => 404 ) ); } if ( 'order_modified' === $claim ) { $latest = orderping_fresh_order( $order_id ) ?? $order; return new WP_REST_Response( array( 'error' => 'order_modified', 'modified' => orderping_order_modified( $latest ), 'freshness' => orderping_order_freshness( $latest ), 'order' => orderping_order_payload( $latest ), ), 409 ); } if ( 'error' === $claim ) { return new WP_Error( 'orderping_storage', 'Storage error', array( 'status' => 503 ) ); } $newly_claimed = 'claimed' === $claim; } // Only the request that newly acquired the claim may mutate. A repeat or an existing claimed row belongs // to another request: read back and report the current state without calling update_status(). if ( ! $newly_claimed ) { $current = orderping_fresh_order( $order_id ); if ( ! $current instanceof WC_Order || ! orderping_order_in_scope( $current ) ) { return new WP_Error( 'orderping_storage', 'Storage error', array( 'status' => 503 ) ); } $current_class = orderping_cancel_classify( $current->get_status() ); return new WP_REST_Response( orderping_cancel_ok( $current, 'already_cancelled' === $current_class ? 'cancelled' : 'claimed' ), 200 ); } // Claimed now. The claim committed some time after the // checks above, so re-read the order from storage (cache-bypassing, as the confirm path does) and // re-validate immediately before the mutation. A change since the claim goes to the staff-flag outcome // and no status change is made. $live = orderping_fresh_order( $order_id ); if ( ! $live instanceof WC_Order || ! orderping_order_in_scope( $live ) ) { return new WP_Error( 'orderping_storage', 'Storage error', array( 'status' => 503 ) ); } $live_class = orderping_cancel_classify( $live->get_status() ); if ( 'already_cancelled' === $live_class || 'shipped' === $live_class || 'not_cancellable' === $live_class ) { return new WP_REST_Response( orderping_cancel_ok( $live, $live_class ), 200 ); } if ( orderping_order_freshness( $live ) !== $freshness ) { return new WP_REST_Response( array( 'error' => 'order_modified', 'modified' => orderping_order_modified( $live ), 'freshness' => orderping_order_freshness( $live ), 'order' => orderping_order_payload( $live ), ), 409 ); } // Remaining window: WooCommerce has no compare-and-set status write, so an edit or fulfilment landing // between this read and update_status() below (a few milliseconds, no I/O in between) can still be // overwritten. $live->update_status( 'cancelled', 'CommercePing: customer cancelled this order on WhatsApp. ' ); $after = orderping_fresh_order( $order_id ); if ( ! $after instanceof WC_Order || 'cancelled' !== $after->get_status() ) { return new WP_REST_Response( orderping_cancel_ok( $after instanceof WC_Order ? $after : $order, 'claimed' ), 200 ); } global $wpdb; $table = orderping_cancel_action_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET result = 'done' WHERE order_id = %d AND action_id = %s", $order_id, $action_id ) ); return new WP_REST_Response( orderping_cancel_ok( $after, 'cancelled' ), 200 ); }