logging_service = $logging_service; } /** * Process payment using SharedPaymentToken * * @param array $checkout_session Session data. * @param array $payment_data Payment data containing SharedPaymentToken. * @return array|WP_Error Payment result or error. */ public function process_payment( array $checkout_session, array $payment_data ): array|WP_Error { try { // Extract SharedPaymentToken. if ( empty( $payment_data['payment']['token'] ) ) { return new WP_Error( 'missing_payment_token', 'SharedPaymentToken is required', array( 'status' => 400 ) ); } $shared_payment_token = sanitize_text_field( $payment_data['payment']['token'] ); // Validate token format (spt_xxxxx). if ( ! str_starts_with( $shared_payment_token, 'spt_' ) ) { return new WP_Error( 'invalid_token_format', 'Invalid SharedPaymentToken format', array( 'status' => 400 ) ); } // Detect fake test tokens (spt_test_xxxxx). // Fake tokens are generated by MockSimulator for testing the plugin flow. // Skip actual Stripe processing for fake tokens to avoid failed orders during testing. $is_fake_token = str_starts_with( $shared_payment_token, 'spt_test_' ); // Create WooCommerce order programmatically. $order = wc_create_order(); if ( is_wp_error( $order ) ) { return $order; } // Validate items array exists (defensive check - should be validated earlier). if ( empty( $checkout_session['items'] ) || ! is_array( $checkout_session['items'] ) ) { $order->delete( true ); return new WP_Error( 'missing_items', 'Checkout session contains no items', array( 'status' => 400 ) ); } // Add line items from checkout session. foreach ( $checkout_session['items'] as $item ) { $product = wc_get_product( $item['product_id'] ); if ( ! $product ) { $order->delete( true ); return new WP_Error( 'product_not_found', sprintf( 'Product %d not found', $item['product_id'] ), array( 'status' => 404 ) ); } $order->add_product( $product, $item['quantity'] ); } // Set billing address. if ( ! empty( $payment_data['billing_address'] ) ) { $billing = $this->normalize_address( $payment_data['billing_address'] ); $order->set_address( $billing, 'billing' ); } // Set shipping address. if ( ! empty( $payment_data['shipping_address'] ) ) { $shipping = $this->normalize_address( $payment_data['shipping_address'] ); $order->set_address( $shipping, 'shipping' ); } // Add shipping line item. if ( ! empty( $checkout_session['selected_shipping_method'] ) && ! empty( $checkout_session['shipping_options'] ) ) { $shipping_method = $this->get_selected_shipping_method( $checkout_session['shipping_options'], $checkout_session['selected_shipping_method'] ); if ( $shipping_method ) { $shipping_item = new WC_Order_Item_Shipping(); $shipping_item->set_method_title( $shipping_method['label'] ); $shipping_item->set_method_id( $shipping_method['id'] ); $shipping_item->set_total( $shipping_method['amount']['value'] ); $order->add_item( $shipping_item ); } } // Set payment method BEFORE calculating totals. // Payment method must be set first as some gateways may add fees or modify calculations. $order->set_payment_method( 'stripe' ); $order->set_payment_method_title( 'Stripe (ChatGPT Checkout)' ); // Add ChatGPT checkout metadata before save. // Metadata should be set before first save for efficiency and proper order tracking. $order->update_meta_data( '_chatgpt_checkout', 'yes' ); $order->update_meta_data( '_chatgpt_session_id', $checkout_session['session_id'] ?? '' ); // Calculate totals after all items and payment method are set. // This calculates taxes, shipping, and final order total based on all line items. $order->calculate_totals(); // Set order status explicitly for clarity. $order->set_status( 'pending', 'Order created via ChatGPT Instant Checkout', true ); // Save order once with all data set - reduces database operations. $order->save(); // Add prominent note at top of order notes indicating ChatGPT origin. // This note appears first in the order timeline for easy identification. $session_id = $checkout_session['session_id'] ?? 'unknown'; $order->add_order_note( sprintf( 'ChatGPT Instant Checkout Order - Session ID: %s', $session_id ), false, // Not a customer note. true // Added by system. ); // Manually trigger order_created webhook since woocommerce_new_order already fired. // The woocommerce_new_order hook fires during wc_create_order() BEFORE metadata is set. // We need to send the webhook after _chatgpt_checkout meta is saved. do_action( 'carticy_ai_checkout_order_created', $order->get_id() ); // Store SharedPaymentToken in transient for filter hook to pick up. // Expires in 5 minutes (300 seconds). set_transient( 'carticy_ai_checkout_spt_' . $order->get_id(), $shared_payment_token, 300 ); // If fake token, skip Stripe processing and return simulated success. // Fake tokens are for testing the plugin flow without actual payment processing. if ( $is_fake_token ) { $order->set_status( 'processing', 'Test order completed with fake SharedPaymentToken' ); $order->add_order_note( 'Order created for testing purposes using fake SharedPaymentToken (spt_test_xxx)' ); $order->update_meta_data( '_chatgpt_payment_completed_at', time() ); $order->update_meta_data( '_chatgpt_test_order', 'yes' ); $order->save(); // Reduce stock levels for test order. wc_maybe_reduce_stock_levels( $order->get_id() ); $this->logging_service->debug( sprintf( 'Test order %d completed with fake token (no actual payment processing)', $order->get_id() ), array( 'order_id' => $order->get_id(), 'category' => 'payment', ) ); return array( 'order_id' => $order->get_id(), 'status' => 'completed', ); } // Initialize WooCommerce session if not already set (required for REST API context). // WC Stripe Gateway uses wc_add_notice() which requires WC()->session to exist. // In REST API context, WC()->session is null by default, causing fatal errors. if ( ! isset( WC()->session ) || null === WC()->session ) { WC()->session = new \WC_Session_Handler(); WC()->session->init(); } // Process payment through WC Stripe Gateway. // The wc_stripe_generate_create_intent_request filter will inject SharedPaymentToken. // // Get Stripe gateway instance using the official WooCommerce Stripe Gateway pattern. // // WHY THIS IS CORRECT: // - WC_Stripe::get_instance() returns the singleton instance of the main Stripe plugin controller // - get_main_stripe_gateway() returns the appropriate gateway (UPE or Legacy) based on merchant settings // - This is the OFFICIAL pattern used throughout woocommerce-gateway-stripe plugin itself // // HOW TO VERIFY: // 1. Check woocommerce-gateway-stripe/includes/class-wc-stripe.php lines 812-826 // 2. Search for "get_main_stripe_gateway" usage across the Stripe plugin - used 30+ times // 3. See: includes/class-wc-stripe-order-handler.php, includes/blocks/class-wc-stripe-blocks-support.php // // ALTERNATIVE APPROACHES (NOT RECOMMENDED): // - WC()->payment_gateways()->payment_gateways()["stripe"] - does not handle UPE vs Legacy // - new WC_Gateway_Stripe() - bypasses initialization, breaks UPE mode $gateway = WC_Stripe::get_instance()->get_main_stripe_gateway(); if ( ! $gateway ) { return new WP_Error( 'stripe_gateway_unavailable', 'Stripe payment gateway is not available', array( 'status' => 500 ) ); } // Wrap payment processing in try-catch to handle gateway exceptions. // Stripe Gateway can throw exceptions for validation errors, API failures, etc. try { $result = $gateway->process_payment( $order->get_id() ); } catch ( \Exception $e ) { // Payment gateway threw exception - log and return error. $this->logging_service->log_error( sprintf( 'Stripe Gateway Exception: %s', $e->getMessage() ), 'payment', array( 'order_id' => $order->get_id() ) ); $order->set_status( 'failed', sprintf( 'Payment failed: %s', $e->getMessage() ) ); $order->save(); return new WP_Error( 'payment_gateway_error', $e->getMessage(), array( 'status' => 500 ) ); } // Check for 3D Secure authentication requirement. if ( isset( $result['redirect'] ) ) { return array( 'order_id' => $order->get_id(), 'status' => 'requires_action', 'redirect_url' => $result['redirect'], ); } // Check if payment succeeded. if ( 'success' === $result['result'] ) { // Reload order to get updated status and meta set by Stripe Gateway. // The payment gateway may have changed order status to 'processing' or 'completed' // and added payment-related meta data that we need for proper stock reduction. $order = wc_get_order( $order->get_id() ); if ( ! $order ) { return new WP_Error( 'order_reload_failed', 'Order reload failed after payment processing', array( 'status' => 500 ) ); } $order->add_order_note( 'Payment processed via ChatGPT Instant Checkout' ); // Add debugging metadata for troubleshooting. // Extract PaymentIntent ID from result or order meta. $payment_intent_id = $order->get_meta( '_stripe_intent_id' ); if ( $payment_intent_id ) { $order->update_meta_data( '_chatgpt_payment_intent_id', $payment_intent_id ); } $order->update_meta_data( '_chatgpt_payment_completed_at', time() ); $order->save(); // Reduce stock levels after successful payment. // WooCommerce does NOT automatically reduce stock for programmatically created orders. // Stock is only reduced when order status changes to processing/completed/on-hold. // We use wc_maybe_reduce_stock_levels() which checks if stock was already reduced. $stock_reduced = wc_maybe_reduce_stock_levels( $order->get_id() ); if ( $stock_reduced ) { $order->add_order_note( 'Stock levels reduced for ChatGPT order' ); } else { // Stock reduction failed or products don't manage stock. // Log warning but don't fail the order - payment succeeded. $order->add_order_note( 'Warning: Stock levels could not be reduced automatically' ); $this->logging_service->log_error( sprintf( 'Failed to reduce stock for order %d. Products may not manage stock or stock already reduced.', $order->get_id() ), 'payment', array( 'order_id' => $order->get_id() ) ); } return array( 'order_id' => $order->get_id(), 'status' => 'completed', ); } // Payment failed - set order status to failed for troubleshooting. $order->set_status( 'failed', 'Payment processing failed via ChatGPT Instant Checkout' ); $order->save(); return new WP_Error( 'payment_failed', 'Payment processing failed', array( 'status' => 402 ) ); } catch ( \Exception $e ) { return new WP_Error( 'payment_exception', $e->getMessage(), array( 'status' => 500 ) ); } } /** * Normalize address format for WooCommerce * * Converts ACP address fields to WooCommerce format. * Per OpenAI Agentic Commerce Protocol, addresses use: line_one, line_two, postal_code, name. * Buyer object uses: first_name, email, phone_number (merged into billing_address before this). * Includes fallback chains for backwards compatibility with alternative field formats. * * @param array $address Address from ACP request (with buyer data merged in). * @return array WooCommerce-formatted address. */ private function normalize_address( array $address ): array { return array( 'first_name' => sanitize_text_field( $address['first_name'] ?? $address['name'] ?? '' ), 'last_name' => sanitize_text_field( $address['last_name'] ?? '' ), 'company' => sanitize_text_field( $address['company'] ?? '' ), 'address_1' => sanitize_text_field( $address['address_1'] ?? $address['line_one'] ?? $address['line1'] ?? '' ), 'address_2' => sanitize_text_field( $address['address_2'] ?? $address['line_two'] ?? $address['line2'] ?? '' ), 'city' => sanitize_text_field( $address['city'] ?? '' ), 'state' => sanitize_text_field( $address['state'] ?? '' ), 'postcode' => sanitize_text_field( $address['postcode'] ?? $address['postal_code'] ?? '' ), 'country' => sanitize_text_field( $address['country'] ?? '' ), 'email' => sanitize_email( $address['email'] ?? '' ), 'phone' => sanitize_text_field( $address['phone'] ?? $address['phone_number'] ?? '' ), ); } /** * Get selected shipping method from options * * @param array> $shipping_options Available shipping options. * @param string $selected_method_id Selected method ID. * @return array|null Shipping method data or null. */ private function get_selected_shipping_method( array $shipping_options, string $selected_method_id ): ?array { foreach ( $shipping_options as $option ) { if ( $option['id'] === $selected_method_id ) { return $option; } } return null; } }