diff --git a/modules/ppcp-agentic-commerce/services.php b/modules/ppcp-agentic-commerce/services.php index d05158487..44747e673 100644 --- a/modules/ppcp-agentic-commerce/services.php +++ b/modules/ppcp-agentic-commerce/services.php @@ -10,6 +10,7 @@ namespace WooCommerce\PayPalCommerce\AgenticCommerce; use Automattic\WooCommerce\Enums\ProductType; +use WooCommerce\PayPalCommerce\AgenticCommerce\Endpoint\CheckoutEndpoint; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\AgenticCommerce\Response\ResponseFactory; use WooCommerce\PayPalCommerce\AgenticCommerce\Endpoint\CreateCartEndpoint; @@ -74,6 +75,14 @@ ); }, + 'agentic.rest.checkout' => static function ( ContainerInterface $container ): CheckoutEndpoint { + return new CheckoutEndpoint( + $container->get( 'agentic.auth.service' ), + $container->get( 'agentic.session.handler' ), + $container->get( 'agentic.response.factory' ) + ); + }, + // Ingestion services. 'agentic.ingestion-eligible-product-types' => static function ( ContainerInterface $container ) { return array( diff --git a/modules/ppcp-agentic-commerce/src/AgenticCommerceModule.php b/modules/ppcp-agentic-commerce/src/AgenticCommerceModule.php index 1c3a26878..e45114d2a 100644 --- a/modules/ppcp-agentic-commerce/src/AgenticCommerceModule.php +++ b/modules/ppcp-agentic-commerce/src/AgenticCommerceModule.php @@ -34,6 +34,7 @@ class AgenticCommerceModule implements ServiceModule, ExecutableModule { 'agentic.rest.get_cart', 'agentic.rest.update_cart', 'agentic.rest.replace_cart', + 'agentic.rest.checkout', ); public function services(): array { diff --git a/modules/ppcp-agentic-commerce/src/Endpoint/CheckoutEndpoint.php b/modules/ppcp-agentic-commerce/src/Endpoint/CheckoutEndpoint.php new file mode 100644 index 000000000..3bcacb7ab --- /dev/null +++ b/modules/ppcp-agentic-commerce/src/Endpoint/CheckoutEndpoint.php @@ -0,0 +1,287 @@ +[a-zA-Z0-9_-]+)/checkout'; + + /** + * The expected HTTP method. + */ + protected const METHOD = 'POST'; + + /** + * Register REST API routes. + * + * @return void + */ + public function register_routes(): void { + register_rest_route( + self::NAMESPACE, + self::PATH, + array( + 'methods' => self::METHOD, + 'callback' => array( $this, 'complete_checkout' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'args' => array( + 'cart_id' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => function ( $param ) { + return ! empty( $param ); + }, + ), + ), + ) + ); + } + + /** + * Complete the checkout process. + * + * @param WP_REST_Request $request The REST request. + * @return WP_REST_Response The REST response. + */ + public function complete_checkout( WP_REST_Request $request ): WP_REST_Response { + $cart_id = $request->get_param( 'cart_id' ); + $data = $this->parse_json_body( $request ); + + if ( $data instanceof AgenticError ) { + return $this->error( $data ); + } + + $payment_method = PaymentMethod::from_array( $data['payment_method'] ); + $payment_method_issues = $payment_method->validate(); + + if ( ! empty( $payment_method_issues ) ) { + return $this->error( + new InternalServerError( + 'Payment method is required for checkout', + $payment_method_issues + ) + ); + } + + // Load the cart session. + $cart_session = $this->session_handler->load_cart_session( $cart_id ); + if ( ! $cart_session ) { + return $this->error( new NotFoundError( 'Cart not found: ' . $cart_id ) ); + } + + // Parse the incoming cart data. + try { + $cart = PayPalCart::from_array( $data ); + } catch ( \Exception $e ) { + return $this->error( + new InternalServerError( 'Invalid cart data: ' . $e->getMessage() ) + ); + } + + // Validate products exist in WooCommerce before proceeding. + $validation_issues = $this->validate_products_exist( $cart ); + $validation_issues = array_merge( $validation_issues, $this->verify_inventory( $cart ) ); + if ( ! empty( $validation_issues ) ) { + $cart = $cart->with_validation_issues( ...$validation_issues ); + return $this->cart_details( $this->response_factory->active_cart( $cart, $cart_id, $cart_session['ec_token'] ) ); + } + + try { + // Create WooCommerce order. + $order = $this->create_wc_order( $cart, $payment_method ); + if ( is_wp_error( $order ) ) { + return $this->error( + InternalServerError::from_wp_error( $order ) + ); + } + + // Remove session. + $this->session_handler->destroy_cart_session( $cart_id ); + + // Build the response with payment confirmation. + $response = $this->response_factory->from_order( + $order, + $cart + ); + + return $this->cart_details( $response ); + + } catch ( \Exception $e ) { + return $this->error( + new InternalServerError( + 'A temporary system error occurred. Please try again later.' + ) + ); + } + } + + /** + * Verify inventory availability using WooCommerce stock management. + * + * @param PayPalCart $cart The cart to verify. + * @return ValidationIssue[] Array of validation issues if any. + */ + private function verify_inventory( PayPalCart $cart ): array { + $issues = array(); + + foreach ( $cart->items() as $item ) { + // Get WooCommerce product. + $product_id = wc_get_product_id_by_sku( $item->variant_id() ); + if ( ! $product_id ) { + $product_id = wc_get_product_id_by_sku( $item->item_id() ); + } + + if ( ! $product_id ) { + continue; // Skip if product not found. + } + + $product = wc_get_product( $product_id ); + if ( ! $product ) { + continue; + } + + // Check stock status. + if ( ! $product->is_in_stock() ) { + $issues[] = new ItemOutOfStock( + 'Product is no longer available', + sprintf( '%s is currently out of stock.', $product->get_name() ), + ); + } + + // Check quantity if managing stock. + if ( $product->managing_stock() ) { + $stock_quantity = $product->get_stock_quantity(); + if ( $stock_quantity < $item->quantity() ) { + $issues[] = new InsufficientQuantity( + 'Insufficient inventory', + // TODO should we actually expose the real stock qty here? + sprintf( + 'Only %d of %s available, but %d requested.', + $stock_quantity, + $product->get_name(), + $item->quantity() + ), + ); + } + } + } + + return $issues; + } + + /** + * Create a WooCommerce order from the cart data. + * + * @param PayPalCart $cart The cart data. + * @param PaymentMethod $payment_method The payment method data. + * @return \WC_Order|\WP_Error The created order or error. + */ + private function create_wc_order( PayPalCart $cart, PaymentMethod $payment_method ) { + // TODO This is placeholder code. We need a translation from PayPalCart -> WC_Cart -> WC_Order here + try { + $order = wc_create_order(); + + // Add items to order. + foreach ( $cart->items() as $item ) { + $product_id = wc_get_product_id_by_sku( $item->variant_id() ); + if ( ! $product_id ) { + $product_id = wc_get_product_id_by_sku( $item->item_id() ); + } + + if ( $product_id ) { + $product = wc_get_product( $product_id ); + if ( $product ) { + $order->add_product( $product, $item->quantity() ); + } + } + } + + // Set customer information. + $customer = $cart->customer(); + if ( $customer ) { + if ( $customer->email_address() ) { + $order->set_billing_email( $customer->email_address() ); + } + + $name = $customer->name(); + if ( $name ) { + $order->set_billing_first_name( $name['given_name'] ?? '' ); + $order->set_billing_last_name( $name['surname'] ?? '' ); + } + + $phone = $customer->phone(); + if ( $phone ) { + $order->set_billing_phone( + '+' . $phone['country_code'] . $phone['national_number'] + ); + } + } + + // Set addresses. + $shipping_address = $cart->shipping_address(); + if ( $shipping_address ) { + $order->set_shipping_address_1( $shipping_address->get_address_line_1() ); + $order->set_shipping_address_2( $shipping_address->get_address_line_2() ?? '' ); + $order->set_shipping_city( $shipping_address->get_admin_area_2() ); + $order->set_shipping_state( $shipping_address->get_admin_area_1() ); + $order->set_shipping_postcode( $shipping_address->get_postal_code() ); + $order->set_shipping_country( $shipping_address->get_country_code() ); + } + + $billing_address = $cart->billing_address(); + if ( $billing_address ) { + $order->set_billing_address_1( $billing_address->get_address_line_1() ); + $order->set_billing_address_2( $billing_address->get_address_line_2() ?? '' ); + $order->set_billing_city( $billing_address->get_admin_area_2() ); + $order->set_billing_state( $billing_address->get_admin_area_1() ); + $order->set_billing_postcode( $billing_address->get_postal_code() ); + $order->set_billing_country( $billing_address->get_country_code() ); + } + + // Set payment method. + $order->set_payment_method( 'ppcp-gateway' ); + $order->set_payment_method_title( 'PayPal' ); + + // Store PayPal token for processing. + $order->update_meta_data( '_paypal_token', $payment_method->token() ); + $order->update_meta_data( '_paypal_payer_id', $payment_method->payer_id() ); + + // Calculate totals. + $order->calculate_totals(); + + // Save the order. + $order->save(); + + return $order; + + } catch ( \Exception $e ) { + return new \WP_Error( 'order_creation_failed', $e->getMessage() ); + } + } +} diff --git a/modules/ppcp-agentic-commerce/src/Response/PaidCartResponse.php b/modules/ppcp-agentic-commerce/src/Response/PaidCartResponse.php index c3ba8b277..1ce7ac7d6 100644 --- a/modules/ppcp-agentic-commerce/src/Response/PaidCartResponse.php +++ b/modules/ppcp-agentic-commerce/src/Response/PaidCartResponse.php @@ -21,6 +21,7 @@ class PaidCartResponse extends CartResponse { public function __construct( PayPalCart $cart, WC_Order $wc_order ) { parent::__construct( $cart ); $this->wc_order = $wc_order; + $this->status = 'COMPLETED'; } public function to_array(): array { diff --git a/modules/ppcp-agentic-commerce/src/Session/AgenticSessionHandler.php b/modules/ppcp-agentic-commerce/src/Session/AgenticSessionHandler.php index 529fec361..13b4f7f65 100644 --- a/modules/ppcp-agentic-commerce/src/Session/AgenticSessionHandler.php +++ b/modules/ppcp-agentic-commerce/src/Session/AgenticSessionHandler.php @@ -136,4 +136,26 @@ public function update_cart_session( string $session_id, PayPalCart $cart ): boo return true; } + + /** + * Destroy/cleanup a cart session by ID. + * + * @param string $session_id The session ID to destroy. + * @return bool True on success, false if session not found or cleanup failed. + */ + public function destroy_cart_session( string $session_id ): bool { + // First verify the session exists by trying to load it. + if ( ! $this->session->load_session_by_id( $session_id ) ) { + return false; + } + + // Clear the agentic commerce data from the session. + $this->session->set( self::SESSION_KEY, null ); + $this->session->save_data(); + + // Destroy the entire session to clean up completely. + $this->session->delete_session( $session_id ); + + return true; + } }