*/ function orderping_order_state_notes() { return array( 'confirmed' => 'CommercePing: customer confirmed this order on WhatsApp.', 'cancelled' => 'CommercePing: this order was cancelled (confirmation resolved as cancelled).', 'unconfirmed' => 'CommercePing: this order is not confirmed (no reply before the deadline).', ); } /** * Optional reason a state was reached, sent by backends that know it. A reason only changes the * note text; the state vocabulary is unchanged, so a plugin that predates reasons still accepts the * same request (it ignores the extra field and writes the deadline wording). * * @return array> state => reason => note. */ function orderping_order_state_reason_notes() { return array( 'unconfirmed' => array( 'edited_after_confirmed' => 'CommercePing: this order was edited after the customer confirmed it, so it needs a fresh confirmation.', ), ); } /** * Note text for a state and optional reason. An unknown reason falls back to the state's note. * * @param string $state Allowed state. * @param string $reason Reason or empty string. * @return string */ function orderping_order_state_note( $state, $reason ) { $notes = orderping_order_state_notes(); $reasons = orderping_order_state_reason_notes(); return $reasons[ $state ][ $reason ] ?? $notes[ $state ]; } /** * Sets the state meta when it differs. Returns 'applied' when it wrote, 'unchanged' when the * order already carried the state. * * @param WC_Order $order Order. * @param string $state One of the allowed states. * @param string $reason Optional reason; selects the note text only. * @return string */ function orderping_apply_order_state( WC_Order $order, $state, $reason = '' ) { if ( (string) $order->get_meta( ORDERPING_STATE_META, true ) === $state ) { return 'unchanged'; } $order->update_meta_data( ORDERPING_STATE_META, $state ); $order->save_meta_data(); $order->add_order_note( orderping_order_state_note( $state, $reason ) ); return 'applied'; } /** * Handles a signed order-state write. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function orderping_rest_order_state( 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_ORDER_STATE_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 ) || ! is_string( $data['state'] ?? null ) || ! array_key_exists( $data['state'], orderping_order_state_notes() ) || ! is_string( $data['action_id'] ?? null ) || ! preg_match( '/^[A-Za-z0-9:_.-]{1,160}$/', $data['action_id'] ) || ! is_string( $data['order_id'] ?? null ) || ! preg_match( '/^\d{1,20}$/', $data['order_id'] ) ) { return new WP_Error( 'orderping_invalid', 'Invalid request', array( 'status' => 400 ) ); } $order = orderping_fresh_order( (int) $data['order_id'] ); // Pilot scope and capture first: nothing about any other order is read or written. 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 ) ); } $reason = is_string( $data['reason'] ?? null ) ? $data['reason'] : ''; $status = orderping_apply_order_state( $order, $data['state'], $reason ); return new WP_REST_Response( array( 'status' => $status, 'state' => $data['state'], ), 200 ); }