'GET', 'callback' => array( $this, 'get_store_info' ), 'permission_callback' => '__return_true', ) ); register_rest_route( 'agentcart/v1', '/cart/create', array( 'methods' => 'POST', 'callback' => array( $this, 'create_cart' ), 'permission_callback' => array( $this, 'check_wc_auth' ), 'args' => array( 'items' => array( 'required' => true, 'type' => 'array', 'description' => __( 'Array of items to add to cart.', 'agentcart' ), 'validate_callback' => function ( $value ) { return is_array( $value ) && ! empty( $value ); }, ), 'attributes' => array( 'required' => false, 'type' => 'object', 'default' => array(), ), ), ) ); register_rest_route( 'agentcart/v1', '/cart/(?P[a-f0-9-]+)', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_cart' ), 'permission_callback' => array( $this, 'check_wc_auth' ), ), array( 'methods' => 'PUT', 'callback' => array( $this, 'update_cart' ), 'permission_callback' => array( $this, 'check_wc_auth' ), ), array( 'methods' => 'DELETE', 'callback' => array( $this, 'delete_cart' ), 'permission_callback' => array( $this, 'check_wc_auth' ), ), ) ); // Fulfillment: POST /agentcart/v1/cart/{token}/shipping-rates register_rest_route( 'agentcart/v1', '/cart/(?P[a-f0-9-]+)/shipping-rates', array( 'methods' => 'POST', 'callback' => array( $this, 'get_shipping_rates' ), 'permission_callback' => array( $this, 'check_wc_auth' ), ) ); // Discounts: PUT /agentcart/v1/cart/{token}/coupons register_rest_route( 'agentcart/v1', '/cart/(?P[a-f0-9-]+)/coupons', array( 'methods' => 'PUT', 'callback' => array( $this, 'apply_coupon_codes' ), 'permission_callback' => array( $this, 'check_wc_auth' ), ) ); register_rest_route( 'agentcart/v1', '/orders/(?P\d+)', array( 'methods' => 'GET', 'callback' => array( $this, 'get_order' ), 'permission_callback' => array( $this, 'check_wc_auth' ), ) ); register_rest_route( 'agentcart/v1', '/health', array( 'methods' => 'GET', 'callback' => array( $this, 'get_health' ), 'permission_callback' => '__return_true', ) ); } /** * Verify WC REST API Basic Auth credentials. * * @param WP_REST_Request $request The incoming request. * @return bool True if authenticated. */ public function check_wc_auth( $request ) { $auth = $request->get_header( 'authorization' ); if ( ! $auth || stripos( $auth, 'basic ' ) !== 0 ) { return false; } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode $decoded = base64_decode( substr( $auth, 6 ) ); if ( ! $decoded || strpos( $decoded, ':' ) === false ) { return false; } list( $consumer_key, $consumer_secret ) = explode( ':', $decoded, 2 ); global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Authenticating the incoming request against our WooCommerce API key; WooCommerce exposes no API for this read and the secret must not be cached. $key = $wpdb->get_row( $wpdb->prepare( "SELECT consumer_secret, permissions FROM {$wpdb->prefix}woocommerce_api_keys WHERE consumer_key = %s", wc_api_hash( $consumer_key ) ) ); if ( ! $key ) { return false; } if ( ! hash_equals( $key->consumer_secret, $consumer_secret ) ) { return false; } // Cart creation requires read_write permissions. if ( ! in_array( $key->permissions, array( 'read_write', 'write' ), true ) ) { return false; } return true; } /** * GET /agentcart/v1/store-info — public store metadata. * * @return WP_REST_Response */ public function get_store_info() { $product_count = wp_count_posts( 'product' ); return rest_ensure_response( array( 'store_name' => get_bloginfo( 'name' ), 'site_url' => get_site_url(), 'currency' => get_woocommerce_currency(), 'wc_version' => WC()->version, 'wp_version' => get_bloginfo( 'version' ), 'plugin_version' => AGENTCART_VERSION, 'timezone' => wp_timezone_string(), 'product_count' => isset( $product_count->publish ) ? (int) $product_count->publish : 0, ) ); } /** * POST /agentcart/v1/cart/create — build a cart and return checkout URL. * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response|WP_Error */ public function create_cart( $request ) { $params = $request->get_json_params(); $items = isset( $params['items'] ) ? $params['items'] : array(); $attributes = isset( $params['attributes'] ) ? $params['attributes'] : array(); if ( empty( $items ) ) { return new WP_Error( 'no_items', __( 'No items provided.', 'agentcart' ), array( 'status' => 400 ) ); } $this->ensure_wc_runtime(); WC()->cart->empty_cart(); $added_items = array(); foreach ( $items as $item ) { $product_id = isset( $item['product_id'] ) ? absint( $item['product_id'] ) : 0; $variation_id = isset( $item['variation_id'] ) ? absint( $item['variation_id'] ) : 0; $quantity = isset( $item['quantity'] ) ? absint( $item['quantity'] ) : 1; if ( $product_id <= 0 ) { continue; } // Validate product exists and is purchasable. $product = wc_get_product( $variation_id ? $variation_id : $product_id ); if ( ! $product || ! $product->is_purchasable() ) { continue; } $cart_item_data = array(); if ( ! empty( $attributes['agentcart_session_id'] ) ) { $cart_item_data['agentcart_session_id'] = sanitize_text_field( $attributes['agentcart_session_id'] ); } $cart_item_key = WC()->cart->add_to_cart( $product_id, $quantity, $variation_id, array(), $cart_item_data ); if ( $cart_item_key ) { $image = wp_get_attachment_image_url( $product->get_image_id(), 'medium' ); $added_items[] = array( 'product_id' => $product_id, 'variation_id' => $variation_id ?: null, 'name' => $product->get_name(), 'quantity' => $quantity, 'price' => $product->get_price(), 'image_url' => $image ?: null, ); } } if ( empty( $added_items ) ) { return new WP_Error( 'cart_empty', __( 'No valid items could be added to cart.', 'agentcart' ), array( 'status' => 400 ) ); } WC()->cart->calculate_totals(); // Store cart in transient for checkout restoration. $cart_token = wp_generate_uuid4(); set_transient( 'agentcart_cart_' . $cart_token, array( 'items' => $items, 'attributes' => $attributes, ), DAY_IN_SECONDS ); $checkout_url = add_query_arg( 'agentcart_cart', $cart_token, wc_get_checkout_url() ); return rest_ensure_response( array( 'checkout_url' => $checkout_url, 'cart_token' => $cart_token, 'subtotal' => WC()->cart->get_subtotal(), 'tax' => WC()->cart->get_total_tax(), 'total' => WC()->cart->get_total( 'edit' ), 'currency' => get_woocommerce_currency(), 'items' => $added_items, ) ); } /** * GET /agentcart/v1/cart/{token} — retrieve cart state. * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response|WP_Error */ public function get_cart( $request ) { $token = $request->get_param( 'token' ); $cart_data = get_transient( 'agentcart_cart_' . $token ); if ( ! $cart_data || ! is_array( $cart_data ) ) { return new WP_Error( 'not_found', __( 'Cart not found or expired.', 'agentcart' ), array( 'status' => 404 ) ); } $this->ensure_wc_runtime(); WC()->cart->empty_cart(); $items = isset( $cart_data['items'] ) ? $cart_data['items'] : array(); $added_items = array(); foreach ( $items as $item ) { $product_id = isset( $item['product_id'] ) ? absint( $item['product_id'] ) : 0; $variation_id = isset( $item['variation_id'] ) ? absint( $item['variation_id'] ) : 0; $quantity = isset( $item['quantity'] ) ? absint( $item['quantity'] ) : 1; if ( $product_id <= 0 ) { continue; } $product = wc_get_product( $variation_id ? $variation_id : $product_id ); if ( ! $product ) { continue; } $cart_item_key = WC()->cart->add_to_cart( $product_id, $quantity, $variation_id ); if ( $cart_item_key ) { $image = wp_get_attachment_image_url( $product->get_image_id(), 'medium' ); $added_items[] = array( 'product_id' => $product_id, 'variation_id' => $variation_id ?: null, 'name' => $product->get_name(), 'quantity' => $quantity, 'price' => $product->get_price(), 'image_url' => $image ?: null, ); } } WC()->cart->calculate_totals(); $checkout_url = add_query_arg( 'agentcart_cart', $token, wc_get_checkout_url() ); return rest_ensure_response( array( 'checkout_url' => $checkout_url, 'cart_token' => $token, 'subtotal' => WC()->cart->get_subtotal(), 'tax' => WC()->cart->get_total_tax(), 'total' => WC()->cart->get_total( 'edit' ), 'currency' => get_woocommerce_currency(), 'items' => $added_items, ) ); } /** * PUT /agentcart/v1/cart/{token} — replace cart items. * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response|WP_Error */ public function update_cart( $request ) { $token = $request->get_param( 'token' ); $cart_data = get_transient( 'agentcart_cart_' . $token ); if ( ! $cart_data || ! is_array( $cart_data ) ) { return new WP_Error( 'not_found', __( 'Cart not found or expired.', 'agentcart' ), array( 'status' => 404 ) ); } $params = $request->get_json_params(); $items = isset( $params['items'] ) ? $params['items'] : array(); if ( empty( $items ) ) { return new WP_Error( 'no_items', __( 'No items provided.', 'agentcart' ), array( 'status' => 400 ) ); } // Update transient with new items, preserve attributes. $attributes = isset( $cart_data['attributes'] ) ? $cart_data['attributes'] : array(); set_transient( 'agentcart_cart_' . $token, array( 'items' => $items, 'attributes' => $attributes, ), DAY_IN_SECONDS ); // Rebuild WC cart with new items. $this->ensure_wc_runtime(); WC()->cart->empty_cart(); $added_items = array(); foreach ( $items as $item ) { $product_id = isset( $item['product_id'] ) ? absint( $item['product_id'] ) : 0; $variation_id = isset( $item['variation_id'] ) ? absint( $item['variation_id'] ) : 0; $quantity = isset( $item['quantity'] ) ? absint( $item['quantity'] ) : 1; if ( $product_id <= 0 ) { continue; } $product = wc_get_product( $variation_id ? $variation_id : $product_id ); if ( ! $product || ! $product->is_purchasable() ) { continue; } $cart_item_key = WC()->cart->add_to_cart( $product_id, $quantity, $variation_id ); if ( $cart_item_key ) { $image = wp_get_attachment_image_url( $product->get_image_id(), 'medium' ); $added_items[] = array( 'product_id' => $product_id, 'variation_id' => $variation_id ?: null, 'name' => $product->get_name(), 'quantity' => $quantity, 'price' => $product->get_price(), 'image_url' => $image ?: null, ); } } WC()->cart->calculate_totals(); $checkout_url = add_query_arg( 'agentcart_cart', $token, wc_get_checkout_url() ); return rest_ensure_response( array( 'checkout_url' => $checkout_url, 'cart_token' => $token, 'subtotal' => WC()->cart->get_subtotal(), 'tax' => WC()->cart->get_total_tax(), 'total' => WC()->cart->get_total( 'edit' ), 'currency' => get_woocommerce_currency(), 'items' => $added_items, ) ); } /** * POST /agentcart/v1/cart/{token}/shipping-rates * * Loads the stored cart, sets the customer shipping destination, recalculates * WooCommerce shipping packages, and returns available rates in UCP FulfillmentGroup format. * * Request body: { "destination": { "first_name", "last_name", "street_address", * "address_locality", "address_region", "postal_code", "address_country" } } * * Response: { "groups": [{ "id", "line_item_ids", "options": [{ "id", "title", "amount", "currency" }] }] } * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response|WP_Error */ public function get_shipping_rates( $request ) { $token = $request->get_param( 'token' ); $cart_data = get_transient( 'agentcart_cart_' . $token ); if ( ! $cart_data || ! is_array( $cart_data ) ) { return new WP_Error( 'not_found', __( 'Cart not found or expired.', 'agentcart' ), array( 'status' => 404 ) ); } $params = $request->get_json_params(); $destination = isset( $params['destination'] ) ? $params['destination'] : array(); $this->ensure_wc_runtime(); WC()->cart->empty_cart(); $items = isset( $cart_data['items'] ) ? $cart_data['items'] : array(); $line_ids = array(); foreach ( $items as $item ) { $product_id = isset( $item['product_id'] ) ? absint( $item['product_id'] ) : 0; $variation_id = isset( $item['variation_id'] ) ? absint( $item['variation_id'] ) : 0; $quantity = isset( $item['quantity'] ) ? absint( $item['quantity'] ) : 1; if ( $product_id <= 0 ) { continue; } $cart_item_key = WC()->cart->add_to_cart( $product_id, $quantity, $variation_id ); if ( $cart_item_key ) { $line_ids[] = $cart_item_key; } } // Apply destination to WC customer object so shipping methods have address context. // (ensure_wc_runtime above already guarantees WC()->customer is non-null.) $country = isset( $destination['address_country'] ) ? sanitize_text_field( $destination['address_country'] ) : ''; $state = isset( $destination['address_region'] ) ? sanitize_text_field( $destination['address_region'] ) : ''; $postcode = isset( $destination['postal_code'] ) ? sanitize_text_field( $destination['postal_code'] ) : ''; $city = isset( $destination['address_locality'] ) ? sanitize_text_field( $destination['address_locality'] ) : ''; $address_1 = isset( $destination['street_address'] ) ? sanitize_text_field( $destination['street_address'] ) : ''; WC()->customer->set_shipping_country( $country ); WC()->customer->set_shipping_state( $state ); WC()->customer->set_shipping_postcode( $postcode ); WC()->customer->set_shipping_city( $city ); WC()->customer->set_shipping_address( $address_1 ); // Force WC to recalculate shipping. WC()->session->set( 'chosen_shipping_methods', array() ); WC()->cart->calculate_totals(); $currency = get_woocommerce_currency(); $groups = array(); $packages = WC()->shipping()->get_packages(); foreach ( $packages as $package_key => $package ) { $options = array(); foreach ( $package['rates'] as $rate_id => $rate ) { // $rate->cost is in decimal (e.g. 5.99); convert to minor units. $amount = intval( round( floatval( $rate->get_cost() ) * 100 ) ); $options[] = array( 'id' => $rate_id, 'title' => $rate->get_label(), 'carrier' => $rate->get_method_id(), 'amount' => $amount, 'currency' => $currency, ); } $groups[] = array( 'id' => (string) $package_key, 'line_item_ids' => $line_ids, 'options' => $options, 'selected_option_id' => null, ); } return rest_ensure_response( array( 'groups' => $groups ) ); } /** * PUT /agentcart/v1/cart/{token}/coupons * * Replacement semantics: applies the provided list of coupon codes to the cart, * removing any previously stored codes first. Passing [] clears all coupons. * * Request body: { "codes": ["SAVE10", "FREESHIP"] } * * Response: { "applied": [{ "title", "amount", "code", "automatic" }] } * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response|WP_Error */ public function apply_coupon_codes( $request ) { $token = $request->get_param( 'token' ); $cart_data = get_transient( 'agentcart_cart_' . $token ); if ( ! $cart_data || ! is_array( $cart_data ) ) { return new WP_Error( 'not_found', __( 'Cart not found or expired.', 'agentcart' ), array( 'status' => 404 ) ); } $params = $request->get_json_params(); $codes = isset( $params['codes'] ) && is_array( $params['codes'] ) ? $params['codes'] : array(); $this->ensure_wc_runtime(); WC()->cart->empty_cart(); // Rebuild cart items. $items = isset( $cart_data['items'] ) ? $cart_data['items'] : array(); foreach ( $items as $item ) { $product_id = isset( $item['product_id'] ) ? absint( $item['product_id'] ) : 0; $variation_id = isset( $item['variation_id'] ) ? absint( $item['variation_id'] ) : 0; $quantity = isset( $item['quantity'] ) ? absint( $item['quantity'] ) : 1; if ( $product_id <= 0 ) { continue; } $product = wc_get_product( $variation_id ? $variation_id : $product_id ); if ( ! $product || ! $product->is_purchasable() ) { continue; } WC()->cart->add_to_cart( $product_id, $quantity, $variation_id ); } // Remove all current coupons, then apply new set with diagnostic rejection tracking. WC()->cart->remove_coupons(); $applied_codes = array(); $rejected = array(); foreach ( $codes as $raw_code ) { $code = sanitize_text_field( $raw_code ); if ( empty( $code ) ) { continue; } $coupon = new WC_Coupon( $code ); // No matching coupon in WooCommerce DB → invalid. if ( ! $coupon->get_id() ) { $rejected[] = array( 'code' => $code, 'reason' => 'invalid', 'message' => __( 'Coupon code does not exist.', 'agentcart' ), ); continue; } // Validate against current cart (cart total, item types, usage limits, expiry, etc.) // before calling apply_coupon — gives us a specific error code we can map. $discounts = new WC_Discounts( WC()->cart ); $valid = $discounts->is_coupon_valid( $coupon ); if ( is_wp_error( $valid ) ) { $rejected[] = array( 'code' => $code, 'reason' => $this->map_coupon_error_code( (int) $valid->get_error_code() ), 'message' => $valid->get_error_message(), ); continue; } if ( WC()->cart->apply_coupon( $code ) ) { $applied_codes[] = $code; } else { // is_coupon_valid passed but apply_coupon refused — exotic edge case // (e.g. coupon now conflicts with another just-applied one). $rejected[] = array( 'code' => $code, 'reason' => 'not_applicable', ); } } WC()->cart->calculate_totals(); // Build applied[] list. $currency = get_woocommerce_currency(); $applied = array(); foreach ( WC()->cart->get_coupons() as $coupon_code => $coupon_obj ) { $discount_amount = WC()->cart->get_coupon_discount_amount( $coupon_code ); $applied[] = array( 'title' => strtoupper( $coupon_code ), 'code' => strtoupper( $coupon_code ), 'amount' => intval( round( floatval( $discount_amount ) * 100 ) ), 'currency' => $currency, 'automatic' => false, ); } // Persist coupon codes in transient so restore_cart_from_token re-applies them. $cart_data['coupons'] = $applied_codes; set_transient( 'agentcart_cart_' . $token, $cart_data, DAY_IN_SECONDS ); $response = array( 'applied' => $applied ); if ( ! empty( $rejected ) ) { $response['rejected'] = $rejected; } return rest_ensure_response( $response ); } /** * Map WooCommerce coupon error codes (WC_Coupon::E_WC_COUPON_*) to the * generic AgentCart/UCP DiscountRejectionReason enum: * invalid | expired | limit_reached | minimum_not_met | not_applicable * * @param int $err_code WooCommerce coupon error code (integer constant). * @return string Generic reason key. */ /** * Bootstrap WC runtime for REST context: session, cart, AND customer. * * WooCommerce's cart calculations (calculate_totals → calculate_shipping * → get_shipping_packages) read from WC()->customer to determine the * destination country. In a REST request there is no front-end bootstrap, * so the customer is null. As long as the store has zero shipping zones * configured, this is harmless — calculate_shipping is a no-op. The moment * a zone is added, calculate_shipping runs and crashes with * "Call to a member function get_shipping_country() on null". * * Call this before any cart->add_to_cart / cart->calculate_totals. */ private function ensure_wc_runtime() { WC()->frontend_includes(); if ( is_null( WC()->session ) ) { WC()->session = new WC_Session_Handler(); WC()->session->init(); } if ( is_null( WC()->cart ) ) { WC()->cart = new WC_Cart(); } if ( is_null( WC()->customer ) ) { WC()->customer = new WC_Customer( get_current_user_id(), true ); } } private function map_coupon_error_code( $err_code ) { $map = array( 100 => 'invalid', // E_WC_COUPON_INVALID_FILTERED / NOT_VALID 101 => 'invalid', // E_WC_COUPON_INVALID_REMOVED 102 => 'invalid', // E_WC_COUPON_NOT_YOURS_REMOVED 103 => 'limit_reached', // E_WC_COUPON_ALREADY_APPLIED 104 => 'limit_reached', // E_WC_COUPON_ALREADY_APPLIED_GUEST_EMAIL_NEEDED 106 => 'limit_reached', // E_WC_COUPON_USAGE_LIMIT_REACHED 107 => 'expired', // E_WC_COUPON_EXPIRED 108 => 'minimum_not_met', // E_WC_COUPON_MIN_SPEND_LIMIT_NOT_MET 109 => 'not_applicable', // E_WC_COUPON_NOT_APPLICABLE 110 => 'not_applicable', // E_WC_COUPON_NOT_VALID_SALE_ITEMS 111 => 'invalid', // E_WC_COUPON_PLEASE_ENTER 112 => 'minimum_not_met', // E_WC_COUPON_MAX_SPEND_LIMIT_MET 113 => 'not_applicable', // E_WC_COUPON_EXCLUDED_PRODUCTS 114 => 'not_applicable', // E_WC_COUPON_EXCLUDED_CATEGORIES ); return isset( $map[ $err_code ] ) ? $map[ $err_code ] : 'not_applicable'; } /** * DELETE /agentcart/v1/cart/{token} — cancel/delete cart. * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response */ public function delete_cart( $request ) { $token = $request->get_param( 'token' ); delete_transient( 'agentcart_cart_' . $token ); return rest_ensure_response( array( 'deleted' => true ) ); } /** * GET /agentcart/v1/orders/{id} — retrieve order details. * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response|WP_Error */ public function get_order( $request ) { $order_id = absint( $request->get_param( 'id' ) ); $order = wc_get_order( $order_id ); if ( ! $order ) { return new WP_Error( 'not_found', __( 'Order not found.', 'agentcart' ), array( 'status' => 404 ) ); } $session_id = $order->get_meta( 'agentcart_session_id' ); if ( empty( $session_id ) ) { return new WP_Error( 'not_agentcart', __( 'Not an AgentCart order.', 'agentcart' ), array( 'status' => 403 ) ); } $line_items = array(); foreach ( $order->get_items() as $item ) { $product = $item->get_product(); $line_items[] = array( 'id' => $item->get_id(), 'name' => $item->get_name(), 'quantity' => $item->get_quantity(), 'price' => $product ? $product->get_price() : '0', ); } $shipping = $order->get_address( 'shipping' ); return rest_ensure_response( array( 'id' => $order_id, 'status' => $order->get_status(), 'currency' => $order->get_currency(), 'total' => $order->get_total(), 'total_tax' => $order->get_total_tax(), 'shipping_total' => $order->get_shipping_total(), 'line_items' => $line_items, 'billing' => $order->get_address( 'billing' ), 'shipping' => $shipping, 'session_id' => $session_id, ) ); } /** * GET /agentcart/v1/health — connection and version check. * * @return WP_REST_Response */ public function get_health() { return rest_ensure_response( array( 'status' => 'ok', 'connected' => (bool) get_option( 'agentcart_connected', false ), 'plugin_version' => AGENTCART_VERSION, ) ); } /** * Restore cart from agentcart_cart token on checkout page. */ public function restore_cart_from_token() { if ( ! isset( $_GET['agentcart_cart'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return; } $cart_token = sanitize_text_field( wp_unslash( $_GET['agentcart_cart'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $cart_data = get_transient( 'agentcart_cart_' . $cart_token ); if ( ! $cart_data || ! is_array( $cart_data ) ) { return; } WC()->cart->empty_cart(); // Build cart_item_data with agentcart_session_id so it propagates to // order line-item meta automatically when WC creates the order. This // is more robust than relying on WC()->session staying intact across // the wp_safe_redirect → checkout-page → checkout-submission round trip // (which silently breaks on some session-cookie configurations). $attributes = isset( $cart_data['attributes'] ) ? $cart_data['attributes'] : array(); $session_id = isset( $attributes['agentcart_session_id'] ) ? sanitize_text_field( $attributes['agentcart_session_id'] ) : ''; $cart_item_data = $session_id ? array( 'agentcart_session_id' => $session_id ) : array(); $items = isset( $cart_data['items'] ) ? $cart_data['items'] : array(); foreach ( $items as $item ) { $product_id = isset( $item['product_id'] ) ? absint( $item['product_id'] ) : 0; $variation_id = isset( $item['variation_id'] ) ? absint( $item['variation_id'] ) : 0; $quantity = isset( $item['quantity'] ) ? absint( $item['quantity'] ) : 1; if ( $product_id > 0 ) { WC()->cart->add_to_cart( $product_id, $quantity, $variation_id, array(), $cart_item_data ); } } // Belt-and-braces: also store in WC session. if ( $session_id ) { WC()->session->set( 'agentcart_session_id', $session_id ); } // Re-apply any coupon codes that were attached via apply_coupon_codes. $coupons = isset( $cart_data['coupons'] ) ? $cart_data['coupons'] : array(); foreach ( $coupons as $coupon_code ) { $code = sanitize_text_field( $coupon_code ); if ( $code ) { WC()->cart->apply_coupon( $code ); } } // Delete transient (single-use). delete_transient( 'agentcart_cart_' . $cart_token ); // Redirect to clean checkout URL. wp_safe_redirect( wc_get_checkout_url() ); exit; } /** * Attach agentcart_session_id to order meta during checkout. * * Reads from WC()->session first (primary path), then falls back to * cart-item meta (set in restore_cart_from_token). The fallback makes * the attribution chain resilient to session-cookie loss. * * @param WC_Order $order The order being created. */ public function attach_session_id_to_order( $order ) { $session_id = ''; if ( WC()->session ) { $session_id = (string) WC()->session->get( 'agentcart_session_id', '' ); } // Fallback: pull from any cart item — restore_cart_from_token stamps // every item with agentcart_session_id in cart_item_data, so this // works even if WC()->session was wiped along the way. if ( ! $session_id && WC()->cart ) { foreach ( WC()->cart->get_cart() as $cart_item ) { if ( ! empty( $cart_item['agentcart_session_id'] ) ) { $session_id = sanitize_text_field( $cart_item['agentcart_session_id'] ); break; } } } if ( $session_id ) { $order->update_meta_data( 'agentcart_session_id', $session_id ); if ( WC()->session ) { WC()->session->set( 'agentcart_session_id', '' ); } } } /** * Authoritative attribution: fires once per line item being added to the * order. Reads agentcart_session_id directly from cart_item_data (set by * restore_cart_from_token), attaches it as line-item meta, and mirrors * it to order meta on first encounter. * * This is the path WC core actually drives — by the time * woocommerce_checkout_create_order fires, cart_item_data has already * been processed and may or may not be reachable depending on hook order. * * @param WC_Order_Item_Product $item The order line item. * @param string $cart_item_key The cart item key. * @param array $values Cart item array (includes cart_item_data). * @param WC_Order $order The order being created. */ public function attach_session_id_to_line_item( $item, $cart_item_key, $values, $order ) { if ( empty( $values['agentcart_session_id'] ) ) { return; } $session_id = sanitize_text_field( $values['agentcart_session_id'] ); // Set ORDER-level meta only — this is what the webhook handler reads, // and it stays out of the buyer-facing order summary / receipt. // Setting line-item meta with a non-underscore key would render the // session UUID as a public attribute on the order details page. if ( ! $order->get_meta( 'agentcart_session_id' ) ) { $order->update_meta_data( 'agentcart_session_id', $session_id ); } } }