encryption = $encryption; } /** * Create a checkout session via Stripe or WC fallback. * * @param array $cart_items Validated cart items [{product_id, variation_id, quantity, price}]. * @param array $options Options including session_id. * * @return array{provider: string, session_id: string|null, checkout_url: string} * * @throws \RuntimeException If Stripe API call fails. */ public function create_checkout_session( array $cart_items, array $options = array() ): array { if ( ! $this->is_configured() || ! get_option( 'aicommerce_stripe_acp_enabled', false ) ) { return $this->create_wc_fallback_session( $cart_items ); } $line_items = array(); foreach ( $cart_items as $item ) { $product_id = $item['variation_id'] ? $item['variation_id'] : $item['product_id']; $product = wc_get_product( $product_id ); if ( ! $product ) { continue; } $line_items[] = array( 'price_data' => array( 'currency' => strtolower( get_woocommerce_currency() ), 'product_data' => array( 'name' => $product->get_name() ), 'unit_amount' => (int) round( (float) $product->get_price() * 100 ), ), 'quantity' => (int) $item['quantity'], ); } $session_data = array( 'mode' => 'payment', 'line_items' => $line_items, 'success_url' => home_url( '/order-received/' ), 'cancel_url' => function_exists( 'wc_get_cart_url' ) ? wc_get_cart_url() : home_url(), 'metadata' => array( 'aicommerce_session' => $options['session_id'] ?? '' ), ); $response = wp_remote_post( 'https://api.stripe.com/v1/checkout/sessions', array( 'headers' => array( 'Authorization' => 'Bearer ' . $this->get_secret_key(), 'Content-Type' => 'application/x-www-form-urlencoded', ), 'body' => $this->build_stripe_body( $session_data ), 'timeout' => 20, ) ); if ( is_wp_error( $response ) ) { throw new \RuntimeException( esc_html( 'Stripe API error: ' . $response->get_error_message() ) ); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! isset( $body['url'] ) ) { $error_msg = $body['error']['message'] ?? 'Unknown error'; throw new \RuntimeException( esc_html( 'Stripe session creation failed: ' . $error_msg ) ); } return array( 'provider' => 'stripe', 'session_id' => $body['id'], 'checkout_url' => $body['url'], ); } /** * Create a WC-native fallback checkout session. * * Stores cart items in a transient with 10-minute expiry * and returns a WC checkout URL with the session token. * * @param array $cart_items Validated cart items. * * @return array{provider: string, session_id: null, checkout_url: string} */ public function create_wc_fallback_session( array $cart_items ): array { $session_token = bin2hex( random_bytes( 16 ) ); set_transient( 'aicommerce_agent_cart_' . $session_token, $cart_items, 600 ); $checkout_url = add_query_arg( 'aicommerce_agent_cart', $session_token, function_exists( 'wc_get_checkout_url' ) ? wc_get_checkout_url() : home_url( '/checkout/' ) ); return array( 'provider' => 'wc_native', 'session_id' => null, 'checkout_url' => $checkout_url, ); } /** * Verify a Stripe webhook signature. * * Implements manual signature verification without the Stripe PHP SDK. * * @param string $payload Raw request body. * @param string $sig_header Stripe-Signature header value. * * @return array Decoded webhook event. * * @throws \RuntimeException If signature verification fails. */ public function verify_webhook( string $payload, string $sig_header ): array { $secret = $this->encryption->decrypt( get_option( 'aicommerce_stripe_webhook_secret', '' ) ); if ( empty( $secret ) ) { throw new \RuntimeException( 'Stripe webhook secret not configured' ); } $timestamp = null; $signatures = array(); foreach ( explode( ',', $sig_header ) as $part ) { $pair = explode( '=', $part, 2 ); if ( count( $pair ) !== 2 ) { continue; } if ( 't' === $pair[0] ) { $timestamp = (int) $pair[1]; } if ( 'v1' === $pair[0] ) { $signatures[] = $pair[1]; } } if ( null === $timestamp || empty( $signatures ) ) { throw new \RuntimeException( 'Invalid Stripe webhook signature format' ); } // Reject events older than 5 minutes to prevent replay attacks. if ( abs( time() - $timestamp ) > 300 ) { throw new \RuntimeException( 'Stripe webhook timestamp too old' ); } $signed_payload = $timestamp . '.' . $payload; $expected = hash_hmac( 'sha256', $signed_payload, $secret ); $valid = false; foreach ( $signatures as $sig ) { if ( hash_equals( $expected, $sig ) ) { $valid = true; break; } } if ( ! $valid ) { throw new \RuntimeException( 'Invalid Stripe webhook signature' ); } $event = json_decode( $payload, true ); if ( ! $event ) { throw new \RuntimeException( 'Invalid webhook payload JSON' ); } return $event; } /** * Check if Stripe is configured (has a secret key). * * @return bool */ public function is_configured(): bool { return ! empty( get_option( 'aicommerce_stripe_secret_key', '' ) ); } /** * Get the decrypted Stripe secret key. * * @return string */ public function get_secret_key(): string { return $this->encryption->decrypt( get_option( 'aicommerce_stripe_secret_key', '' ) ); } /** * Build URL-encoded body for Stripe API (nested array support). * * @param array $data Data to encode. * @param string $prefix Parent key prefix. * * @return string URL-encoded string. */ private function build_stripe_body( array $data, string $prefix = '' ): string { $parts = array(); foreach ( $data as $key => $value ) { $full_key = $prefix ? "{$prefix}[{$key}]" : $key; if ( is_array( $value ) ) { $parts[] = $this->build_stripe_body( $value, $full_key ); } else { $parts[] = rawurlencode( $full_key ) . '=' . rawurlencode( (string) $value ); } } return implode( '&', array_filter( $parts ) ); } }