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[\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; } }