# woocommerce-pos/1.10.17/includes/API/V1/Checkout_Controller.php

WCPOS – Point of Sale (POS) plugin for WooCommerce, version 1.10.17. 365 lines.

- Page: https://pluginprobe.com/plugins/woocommerce-pos/1.10.17/code/includes/API/V1/Checkout_Controller.php
- Raw: https://pluginprobe.com/plugins/woocommerce-pos/1.10.17/raw/includes/API/V1/Checkout_Controller.php
- Modified: 2026-08-29T23:58:28+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/woocommerce-pos/1.10.17/code/includes/API/V1/Checkout_Controller.php#L10-L20`.

```php
<?php
/**
 * POS checkout controller.
 *
 * @package WCPOS\WooCommercePOS
 */

namespace WCPOS\WooCommercePOS\API\V1;

\defined( 'ABSPATH' ) || die;

use WC_Payment_Gateway;
use WC_Order;
use WC_REST_Controller;
use WCPOS\WooCommercePOS\Payments\Checkout_State_Repository;
use WCPOS\WooCommercePOS\Payments\Gateway_Contract;
use WCPOS\WooCommercePOS\Payments\Idempotency_Repository;
use WCPOS\WooCommercePOS\Services\Stock_Validator;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

/**
 * POS checkout controller.
 */
class Checkout_Controller extends WC_REST_Controller {
	/**
	 * REST namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wcpos/v1';

	/**
	 * REST base.
	 *
	 * @var string
	 */
	protected $rest_base = 'orders';

	/**
	 * Checkout state repository.
	 *
	 * @var Checkout_State_Repository
	 */
	private $state_repository;

	/**
	 * Idempotency repository.
	 *
	 * @var Idempotency_Repository
	 */
	private $idempotency_repository;

	/**
	 * Shared gateway contract helper.
	 *
	 * @var Gateway_Contract
	 */
	private $gateway_contract;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->state_repository       = new Checkout_State_Repository();
		$this->idempotency_repository = new Idempotency_Repository();
		$this->gateway_contract       = new Gateway_Contract();
	}

	/**
	 * Register routes.
	 */
	public function register_routes(): void {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d]+)/checkout',
			array(
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'create_item' ),
					'permission_callback' => array( $this, 'create_item_permissions_check' ),
				),
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item' ),
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
				),
			)
		);
	}

	/**
	 * Read permissions check.
	 *
	 * @param WP_REST_Request $_request Request object.
	 */
	public function get_item_permissions_check( $_request ) {
		return current_user_can( 'publish_shop_orders' )
			? true
			: new WP_Error( 'rest_forbidden', __( 'Sorry, you cannot view checkout state.', 'woocommerce-pos' ), array( 'status' => rest_authorization_required_code() ) );
	}

	/**
	 * Create permissions check.
	 *
	 * @param WP_REST_Request $_request Request object.
	 */
	public function create_item_permissions_check( $_request ) {
		return current_user_can( 'publish_shop_orders' )
			? true
			: new WP_Error( 'rest_forbidden', __( 'Sorry, you cannot process checkout.', 'woocommerce-pos' ), array( 'status' => rest_authorization_required_code() ) );
	}

	/**
	 * Create a checkout state mutation.
	 *
	 * @param WP_REST_Request $request Request object.
	 * @throws \Throwable When gateway processing fails.
	 */
	public function create_item( $request ) {
		$order = $this->get_order( (int) $request['id'] );
		if ( is_wp_error( $order ) ) {
			return $order;
		}

		$params = $request->get_json_params();
		if ( empty( $params ) ) {
			$params = $request->get_body_params();
		}
		if ( empty( $params ) ) {
			$params = $request->get_params();
		}

		$idempotency_key = (string) $request->get_header( 'X-WCPOS-Idempotency-Key' );
		if ( empty( $idempotency_key ) ) {
			return new WP_Error(
				'wcpos_missing_idempotency_key',
				/* translators: REST API schema field label or error message. */
				__( 'Missing X-WCPOS-Idempotency-Key header.', 'woocommerce-pos' ),
				array( 'status' => 400 )
			);
		}

		$gateway_id = isset( $params['gateway_id'] ) ? (string) $params['gateway_id'] : '';
		$gateway    = $this->get_gateway( $gateway_id );
		if ( ! $gateway ) {
			return new WP_Error(
				'wcpos_payment_gateway_not_found',
				/* translators: REST API schema field label or error message. */
				__( 'Payment gateway not found.', 'woocommerce-pos' ),
				array( 'status' => 404 )
			);
		}

		if ( ! $this->gateway_contract->is_pos_enabled( $gateway ) || ! $this->gateway_contract->supports_checkout( $gateway, $request ) ) {
			return new WP_Error(
				'wcpos_payment_gateway_not_available',
				__( 'Payment gateway is not available for POS checkout.', 'woocommerce-pos' ),
				array( 'status' => 400 )
			);
		}

		$idempotency_scope = $this->get_idempotency_scope( $order->get_id() );
		$request_hash      = md5(
			wp_json_encode(
				$this->normalize_for_hash(
					array(
						'order_id' => $order->get_id(),
						'params'   => $params,
					)
				)
			)
		);
		$claim             = $this->idempotency_repository->claim( $idempotency_scope, $idempotency_key, $request_hash );

		if ( is_wp_error( $claim ) ) {
			return $claim;
		}

		if ( is_array( $claim ) ) {
			return new WP_REST_Response( $claim['body'], $claim['status_code'] );
		}

		try {
			$action       = isset( $params['action'] ) ? (string) $params['action'] : 'start';
			$payment_data = isset( $params['payment_data'] ) && is_array( $params['payment_data'] ) ? $params['payment_data'] : array();
			// Validate on EVERY action, not just `start`. The action string is
			// free-form and dispatched to a gateway filter, and the shipped surface
			// already carries `update` alongside `start`, so a gateway completing
			// payment on a later action would otherwise take money for stock that
			// was never checked. validate_checkout() short-circuits when the order
			// already holds a sufficient reservation, so this costs a lookup rather
			// than a second hold.
			$validation = Stock_Validator::instance()->validate_checkout( $order );
			if ( is_wp_error( $validation ) ) {
				Stock_Validator::instance()->release_checkout_stock( $order );

				return $validation;
			}
			try {
				$state = $this->dispatch_checkout_action( $gateway, $order->get_id(), $action, $payment_data, $order, $request );
			} catch ( \Throwable $exception ) {
				// Every action can now be holding stock, so every action gives it
				// back when dispatch fails; the normalized cancelled/failed branch
				// below is never reached on these paths.
				Stock_Validator::instance()->release_checkout_stock( $order );

				throw $exception;
			}

			if ( is_wp_error( $state ) ) {
				Stock_Validator::instance()->release_checkout_stock( $order );

				return $state;
			}

			$state = $this->normalize_state( $order->get_id(), $gateway_id, $state );
			if ( \in_array( $state['status'], array( 'cancelled', 'failed' ), true ) ) {
				Stock_Validator::instance()->release_checkout_stock( $order );
			}
			$this->state_repository->upsert( $order->get_id(), $state );

			if ( 'completed' === $state['status'] ) {
				$order->update_meta_data( '_pos_checkout_gateway_id', $gateway_id );
				$order->update_meta_data( '_pos_checkout_idempotency_key', $idempotency_key );
				$order->save_meta_data();
			}

			$this->idempotency_repository->store( $idempotency_scope, $idempotency_key, $request_hash, 200, $state );

			return rest_ensure_response( $state );
		} finally {
			$this->idempotency_repository->release( $idempotency_scope, $idempotency_key );
		}
	}

	/**
	 * Return the last known checkout state.
	 *
	 * @param WP_REST_Request $request Request object.
	 */
	public function get_item( $request ) {
		$order = $this->get_order( (int) $request['id'] );
		if ( is_wp_error( $order ) ) {
			return $order;
		}

		$state = $this->state_repository->get( $order->get_id() );
		if ( empty( $state ) ) {
			$gateway_id = $order->get_meta( '_pos_checkout_gateway_id', true );

			$state = array(
				'checkout_id'   => null,
				'order_id'      => $order->get_id(),
				'gateway_id'    => $gateway_id ? $gateway_id : '',
				'status'        => 'pending',
				'provider_data' => array(),
				'terminal'      => false,
			);
		}

		return rest_ensure_response( $state );
	}

	/**
	 * Get an order by ID.
	 *
	 * @param int $order_id Order ID.
	 */
	private function get_order( int $order_id ) {
		$order = wc_get_order( $order_id );

		if ( ! $order ) {
			return new WP_Error(
				'wcpos_order_not_found',
				/* translators: REST API schema field label or error message. */
				__( 'Order not found.', 'woocommerce-pos' ),
				array( 'status' => 404 )
			);
		}

		return $order;
	}

	/**
	 * Get a payment gateway by ID.
	 *
	 * @param string $gateway_id Gateway ID.
	 */
	private function get_gateway( string $gateway_id ): ?WC_Payment_Gateway {
		WC()->payment_gateways();
		$gateways = WC()->payment_gateways->payment_gateways();

		return $gateways[ $gateway_id ] ?? null;
	}

	/**
	 * Dispatch checkout processing to the resolved gateway only.
	 *
	 * @param WC_Payment_Gateway $gateway      Gateway object.
	 * @param int                $order_id     Order ID.
	 * @param string             $action       Checkout action.
	 * @param array              $payment_data Payment data.
	 * @param WC_Order           $order        Order object.
	 * @param WP_REST_Request    $request      Request object.
	 *
	 * @return array|WP_Error
	 */
	private function dispatch_checkout_action( WC_Payment_Gateway $gateway, int $order_id, string $action, array $payment_data, WC_Order $order, WP_REST_Request $request ) {
		return $this->gateway_contract->process_checkout_action( $gateway, $order_id, $action, $payment_data, $order, $request );
	}

	/**
	 * Build order-scoped idempotency namespace.
	 *
	 * @param int $order_id Order ID.
	 */
	private function get_idempotency_scope( int $order_id ): string {
		return 'checkout:' . $order_id;
	}

	/**
	 * Normalize checkout state payload.
	 *
	 * @param int    $order_id   Order ID.
	 * @param string $gateway_id Gateway ID.
	 * @param array  $state      Raw state.
	 */
	private function normalize_state( int $order_id, string $gateway_id, array $state ): array {
		$status = (string) ( $state['status'] ?? 'processing' );

		return array(
			'checkout_id'   => $state['checkout_id'] ?? null,
			'order_id'      => $order_id,
			'gateway_id'    => $state['gateway_id'] ?? $gateway_id,
			'status'        => $status,
			'provider_data' => isset( $state['provider_data'] ) && is_array( $state['provider_data'] ) ? $state['provider_data'] : array(),
			'terminal'      => ( isset( $state['terminal'] ) ? (bool) $state['terminal'] : false ) || $this->gateway_contract->is_terminal_status( $status ),
		);
	}

	/**
	 * Normalize request data for idempotency hashing.
	 *
	 * @param mixed $value Raw value.
	 *
	 * @return mixed
	 */
	private function normalize_for_hash( $value ) {
		if ( ! is_array( $value ) ) {
			return $value;
		}

		ksort( $value );

		foreach ( $value as $key => $nested ) {
			$value[ $key ] = $this->normalize_for_hash( $nested );
		}

		return $value;
	}
}

```
