$cart_schema ); AgentAdapt_Capabilities::register( 'cart.view', $common + array( 'summary' => __( 'View the cart', 'agent-adapt' ), 'description' => __( 'This Session\'s cart: items, coupons and totals as the store calculates them, plus the store\'s own cross-sells for what is in it. Each line lists what the store changed since the agent set it (price, stock), lines the store removed are listed, and a cart an earlier Session of this agent and User left is offered as saved_cart.', 'agent-adapt' ), 'method' => 'GET', 'path' => '/cart', 'handler' => static fn( $c, $p ) => self::run( $c, static fn() => null, false ), ) ); AgentAdapt_Capabilities::register( 'cart.add', $common + array( 'summary' => __( 'Add to the cart', 'agent-adapt' ), 'description' => __( 'Add a product (or a specific variation) to this Session\'s cart.', 'agent-adapt' ), 'method' => 'POST', 'path' => '/cart/items', 'status' => 201, 'params' => array( 'product_id' => array( 'type' => 'integer', 'minimum' => 1, 'required' => true, 'description' => __( 'Product or variation ID.', 'agent-adapt' ) ), 'quantity' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => self::MAX_QTY, 'default' => 1 ), 'attributes' => array( 'type' => 'object', 'description' => __( 'For a variation with "any" attributes: attribute name → value.', 'agent-adapt' ) ), ), 'handler' => array( __CLASS__, 'add' ), ) ); AgentAdapt_Capabilities::register( 'cart.update', $common + array( 'summary' => __( 'Change a quantity', 'agent-adapt' ), 'description' => __( 'Set the quantity of one cart line. Zero removes it.', 'agent-adapt' ), 'method' => 'PATCH', 'path' => '/cart/items/{key}', 'params' => array( 'key' => array( 'type' => 'string', 'maxLength' => 64, 'required' => true, 'in' => 'path' ), 'quantity' => array( 'type' => 'integer', 'minimum' => 0, 'maximum' => self::MAX_QTY, 'required' => true ), ), 'handler' => array( __CLASS__, 'update' ), ) ); AgentAdapt_Capabilities::register( 'cart.remove', $common + array( 'summary' => __( 'Remove a line', 'agent-adapt' ), 'description' => __( 'Remove one line from the cart.', 'agent-adapt' ), 'method' => 'DELETE', 'path' => '/cart/items/{key}', 'params' => array( 'key' => array( 'type' => 'string', 'maxLength' => 64, 'required' => true, 'in' => 'path' ) ), 'handler' => array( __CLASS__, 'remove' ), ) ); AgentAdapt_Capabilities::register( 'cart.coupon', $common + array( 'summary' => __( 'Apply a coupon', 'agent-adapt' ), 'description' => __( 'Apply a coupon code the customer has. The store decides whether it is valid.', 'agent-adapt' ), 'method' => 'POST', 'path' => '/cart/coupons', 'params' => array( 'code' => array( 'type' => 'string', 'maxLength' => 100, 'required' => true ) ), 'handler' => array( __CLASS__, 'apply_coupon' ), ) ); AgentAdapt_Capabilities::register( 'cart.uncoupon', $common + array( 'summary' => __( 'Remove a coupon', 'agent-adapt' ), 'description' => __( 'Remove an applied coupon.', 'agent-adapt' ), 'method' => 'DELETE', 'path' => '/cart/coupons/{code}', 'params' => array( 'code' => array( 'type' => 'string', 'maxLength' => 100, 'required' => true, 'in' => 'path' ) ), 'handler' => array( __CLASS__, 'remove_coupon' ), ) ); AgentAdapt_Capabilities::register( 'cart.restore', array( 'summary' => __( 'Restore the saved cart', 'agent-adapt' ), 'description' => __( 'Bring back the cart an earlier Session of this agent and User left (the cart\'s saved_cart). Its lines are merged into this cart: a line already here keeps the larger quantity, so restoring twice adds nothing. Lines the store can no longer sell are listed as skipped. Coupons are not restored. Refused with 404 when there is nothing to restore.', 'agent-adapt' ), 'method' => 'POST', 'path' => '/cart/restore', 'response' => self::schema( true ), 'errors' => array( '404' => __( 'not_found: there is no saved cart to restore for this Session.', 'agent-adapt' ) ), 'handler' => array( 'AgentAdapt_Cart_Continuity', 'restore' ), ) ); } /** * cart.add * * @param AgentAdapt_Context $context Context. * @param array $p Params. * @return array|WP_Error */ public static function add( AgentAdapt_Context $context, array $p ) { $id = (int) $p['product_id']; $qty = (int) ( $p['quantity'] ?? 1 ); if ( ! AgentAdapt_Catalog::resolvable( $id ) ) { return new WP_Error( 'not_found', 'no such product', array( 'status' => 404 ) ); } return self::run( $context, static function () use ( $context, $id, $qty, $p ) { if ( count( WC()->cart->get_cart() ) >= self::MAX_LINES ) { return new WP_Error( 'invalid_request', 'the cart is full', array( 'status' => 400 ) ); } $attrs = array(); foreach ( (array) ( $p['attributes'] ?? array() ) as $k => $v ) { $name = str_starts_with( (string) $k, 'attribute_' ) ? (string) $k : 'attribute_' . sanitize_title( (string) $k ); $attrs[ $name ] = sanitize_text_field( (string) $v ); } $key = WC()->cart->add_to_cart( $id, $qty, 0, $attrs ); if ( ! $key ) { return self::rejection( 'the store would not add that' ); } $product = wc_get_product( $id ); AgentAdapt_Events::record( 'cart.added', $context, array( 'object_id' => $id, 'value' => $product ? (float) $product->get_price() * $qty : null, 'meta' => array( 'quantity' => $qty ) ) ); return null; } ); } /** * cart.update * * @param AgentAdapt_Context $context Context. * @param array $p Params. * @return array|WP_Error */ public static function update( AgentAdapt_Context $context, array $p ) { return self::run( $context, static function () use ( $context, $p ) { $key = (string) $p['key']; $line = WC()->cart->get_cart_item( $key ); if ( ! $line ) { return new WP_Error( 'not_found', 'no such cart line', array( 'status' => 404 ) ); } $qty = (int) $p['quantity']; if ( ! WC()->cart->set_quantity( $key, $qty ) && $qty > 0 ) { return self::rejection( 'the store would not change that quantity' ); } AgentAdapt_Events::record( 0 === $qty ? 'cart.removed' : 'cart.updated', $context, array( 'object_id' => (int) $line['product_id'], 'meta' => array( 'quantity' => $qty ) ) ); return null; } ); } /** * cart.remove * * @param AgentAdapt_Context $context Context. * @param array $p Params. * @return array|WP_Error */ public static function remove( AgentAdapt_Context $context, array $p ) { return self::run( $context, static function () use ( $context, $p ) { $key = (string) $p['key']; $line = WC()->cart->get_cart_item( $key ); if ( ! $line ) { return new WP_Error( 'not_found', 'no such cart line', array( 'status' => 404 ) ); } WC()->cart->remove_cart_item( $key ); AgentAdapt_Events::record( 'cart.removed', $context, array( 'object_id' => (int) $line['product_id'] ) ); return null; } ); } /** * cart.coupon * * @param AgentAdapt_Context $context Context. * @param array $p Params. * @return array|WP_Error */ public static function apply_coupon( AgentAdapt_Context $context, array $p ) { return self::run( $context, static function () use ( $context, $p ) { $code = wc_format_coupon_code( (string) $p['code'] ); $ok = WC()->cart->apply_coupon( $code ); AgentAdapt_Events::record( 'cart.coupon', $context, array( 'outcome' => $ok ? 'applied' : 'rejected' ) ); return $ok ? null : self::rejection( 'that coupon cannot be applied' ); } ); } /** * cart.uncoupon * * @param AgentAdapt_Context $context Context. * @param array $p Params. * @return array|WP_Error */ public static function remove_coupon( AgentAdapt_Context $context, array $p ) { return self::run( $context, static function () use ( $p ) { $code = wc_format_coupon_code( (string) $p['code'] ); if ( ! in_array( $code, WC()->cart->get_applied_coupons(), true ) ) { return new WP_Error( 'not_found', 'that coupon is not applied', array( 'status' => 404 ) ); } WC()->cart->remove_coupon( $code ); return null; } ); } /** * Bind the Session's cart, run a change, recalculate, save, and answer * with the cart. A change that returns WP_Error saves nothing. * * The answer to a change still lists the lines the store removed, and * then they are forgotten: the agent has been told, and has acted on the * cart since (AgentAdapt_Cart_Notices). * * @param AgentAdapt_Context $context Context. * @param callable $change fn(): null|WP_Error. * @param bool $writes Whether this is a change (false for cart.view). * @return array|WP_Error */ public static function run( AgentAdapt_Context $context, callable $change, bool $writes = true ) { self::bind( $context->wc_session_key ); wc_clear_notices(); $result = $change(); if ( is_wp_error( $result ) ) { wc_clear_notices(); return $result; } WC()->cart->calculate_totals(); wc_clear_notices(); $out = self::shape( $context ); if ( $writes ) { AgentAdapt_Cart_Notices::clear_removed(); } WC()->session->save_data(); AgentAdapt_Cart_Notices::record_shown( $context, $out ); return $out; } /** * Make WC()->session / customer / cart this Session's, for this request. * * @param string $key WooCommerce session key. */ public static function bind( string $key ): void { include_once WC_ABSPATH . 'includes/wc-cart-functions.php'; include_once WC_ABSPATH . 'includes/wc-notice-functions.php'; AgentAdapt_WC_Session::$next_key = $key; $handler = static fn() => 'AgentAdapt_WC_Session'; add_filter( 'woocommerce_session_handler', $handler, PHP_INT_MAX ); WC()->session = null; WC()->initialize_session(); remove_filter( 'woocommerce_session_handler', $handler, PHP_INT_MAX ); // Always a guest customer: a Session Token never makes the request a // WordPress login (PLAN.md §5.4 rule 3). Signed-in carts are Phase 2. WC()->customer = new WC_Customer( 0, true ); WC()->cart = new WC_Cart(); WC()->cart->get_cart(); // Loads from the bound session. } /** * The current WooCommerce cart, for an agent. * * @param AgentAdapt_Context|null $context Whose cart (for saved_cart); null leaves it out. * @return array */ public static function shape( ?AgentAdapt_Context $context = null ): array { $cart = WC()->cart; $cur = get_woocommerce_currency(); $m = static fn( $v ) => AgentAdapt_Money::from_decimal( $v, $cur ); $items = array(); foreach ( $cart->get_cart() as $key => $line ) { $product = $line['data'] ?? null; if ( ! $product instanceof WC_Product ) { continue; } $items[] = array( 'key' => (string) $key, 'product_id' => (int) $line['product_id'], 'variation_id' => (int) ( $line['variation_id'] ?? 0 ), 'name' => AgentAdapt_Catalog::text( $product->get_name() ), 'quantity' => (int) $line['quantity'], 'unit_price' => AgentAdapt_Cart_Notices::unit_price( $product ), 'line_subtotal' => $m( $line['line_subtotal'] ?? 0 ), 'line_total' => $m( $line['line_total'] ?? 0 ), 'line_tax' => $m( $line['line_tax'] ?? 0 ), 'url' => (string) $product->get_permalink(), 'in_stock' => $product->is_in_stock(), 'notices' => AgentAdapt_Cart_Notices::for_line( $line, $product ), ); } $coupons = array(); foreach ( $cart->get_applied_coupons() as $code ) { $coupons[] = array( 'code' => (string) $code, 'discount' => $m( $cart->get_coupon_discount_amount( $code, false ) ) ); } return array( 'items' => $items, 'removed_by_store' => AgentAdapt_Cart_Notices::removed(), 'item_count' => (int) $cart->get_cart_contents_count(), 'coupons' => $coupons, 'needs_shipping' => (bool) $cart->needs_shipping(), 'totals' => array( 'subtotal' => $m( $cart->get_subtotal() ), 'discount' => $m( $cart->get_discount_total() ), 'tax' => $m( $cart->get_total_tax() ), // Shipping needs an address; a guest agent cart has none until // checkout, so it is unknown rather than zero. 'shipping' => $cart->needs_shipping() ? null : $m( 0 ), 'total' => $m( $cart->get_total( 'edit' ) ), ), 'currency' => $cur, // The store's own cross-sells for what is in the cart (PLAN.md §12.4). 'related' => array( 'cross_sells' => array_values( array_map( 'intval', $cart->get_cross_sells() ) ) ), // What an earlier Session of this agent and User left (offered, not applied). 'saved_cart' => $context ? AgentAdapt_Cart_Continuity::summary( $context ) : null, ); } /** * A refusal carrying WooCommerce's own reason as plain text. * * @param string $fallback Message when WooCommerce gave none. */ private static function rejection( string $fallback ): WP_Error { return new WP_Error( 'cart_rejected', self::notice_text( $fallback ), array( 'status' => 409 ) ); } /** * WooCommerce's error notices as plain text (then cleared). * * @param string $fallback Text when WooCommerce gave none. */ public static function notice_text( string $fallback ): string { $msgs = array(); foreach ( (array) wc_get_notices( 'error' ) as $n ) { $msgs[] = AgentAdapt_Catalog::text( is_array( $n ) ? (string) ( $n['notice'] ?? '' ) : (string) $n ); } wc_clear_notices(); $text = implode( ' ', array_filter( $msgs ) ); return '' !== $text ? $text : $fallback; } /** * JSON Schema of the cart (and of cart.restore's answer). * * @param bool $restore Add cart.restore's `restore` result. * @return array */ private static function schema( bool $restore = false ): array { $m = AgentAdapt_Money::schema(); $line = array( 'product_id' => array( 'type' => 'integer' ), 'variation_id' => array( 'type' => 'integer' ), 'name' => array( 'type' => 'string' ), 'quantity' => array( 'type' => 'integer' ), ); $props = array( 'items' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'key' => array( 'type' => 'string', 'description' => 'The line, for cart.update and cart.remove.' ), 'unit_price' => $m, 'line_subtotal' => $m, 'line_total' => $m, 'line_tax' => $m, 'url' => array( 'type' => 'string', 'format' => 'uri' ), 'in_stock' => array( 'type' => 'boolean' ), 'notices' => AgentAdapt_Cart_Notices::schema(), ) + $line, ), ), 'removed_by_store' => array( 'type' => 'array', 'description' => 'Lines the store removed from the cart because they can no longer be bought (unpublished, deleted, no price), each with an out_of_stock notice. Listed until the agent next changes the cart.', 'items' => array( 'type' => 'object', 'properties' => $line + array( 'notices' => AgentAdapt_Cart_Notices::schema() ) ), ), 'item_count' => array( 'type' => 'integer' ), 'coupons' => array( 'type' => 'array', 'items' => array( 'type' => 'object' ) ), 'needs_shipping' => array( 'type' => 'boolean' ), 'totals' => array( 'type' => 'object', 'properties' => array( 'subtotal' => $m, 'discount' => $m, 'tax' => $m, 'shipping' => $m, 'total' => $m ) ), 'currency' => array( 'type' => 'string' ), 'related' => array( 'type' => 'object' ), 'saved_cart' => array( 'type' => array( 'object', 'null' ), 'description' => 'A cart an earlier Session of this agent and User left, offered and not applied: cart.restore brings it back. Null when there is none, or once restored. Totals are as that Session last calculated them; coupons are listed, never restored.', 'properties' => array( 'item_count' => array( 'type' => 'integer' ), 'line_count' => array( 'type' => 'integer' ), 'lines' => array( 'type' => 'array', 'description' => 'The first few lines.', 'items' => array( 'type' => 'object', 'properties' => $line ) ), 'totals' => array( 'type' => 'object', 'properties' => array( 'subtotal' => $m, 'total' => $m ) ), 'coupons' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ), 'last_changed' => array( 'type' => 'string', 'format' => 'date-time' ), ), ), ); if ( $restore ) { $props['restore'] = array( 'type' => 'object', 'properties' => array( 'status' => array( 'type' => 'string', 'enum' => array( 'restored', 'already_restored' ) ), 'restored' => array( 'type' => 'integer', 'description' => 'Saved lines now in the cart.' ), 'skipped' => array( 'type' => 'array', 'description' => 'Saved lines that could not be restored: out_of_stock, unavailable (no longer sold, or options that cannot be restored) or rejected (the store refused; message says why).', 'items' => array( 'type' => 'object', 'properties' => $line + array( 'reason' => array( 'type' => 'string', 'enum' => array( 'out_of_stock', 'unavailable', 'rejected' ) ), 'message' => array( 'type' => 'string' ), ), ), ), ), ); } return array( 'type' => 'object', 'properties' => $props ); } }