Coupon Code => discount amount. * * @var array */ protected array $coupon_discount_per_item = []; /** * Rewarded ("free") unit counts: Coupon Code => Item Key => units (int). * * Populated from Discounts by the totals engine. Recorded by add-ons whose * coupon type grants whole free units (e.g. Pro's Buy X Get Y). * * @var array */ protected array $reward_units = []; /** * This stores the chosen shipping methods for the cart item packages. * * @var array */ protected array $shipping_methods = []; /** * Whether the shipping totals have been calculated. This will only return true if shipping was calculated, not if * shipping is disabled or if there are no cart contents. * * @var bool */ protected bool $has_calculated_shipping = false; /** * Total defaults used to reset. * * @var array */ protected array $default_totals = [ 'subtotal' => 0, 'subtotal_tax' => 0, 'shipping_total' => 0, 'shipping_tax' => 0, 'shipping_taxes' => [], 'discount_total' => 0, 'discount_tax' => 0, 'cart_contents_total' => 0, 'cart_contents_tax' => 0, 'cart_contents_taxes' => [], 'fee_total' => 0, 'fee_tax' => 0, 'fee_taxes' => [], 'total' => 0, 'total_tax' => 0, ]; protected array $totals = []; protected array $meta = []; protected bool $is_dirty = false; protected static ?Cart $instance = null; private bool $is_session = false; private ?Customer $customer = null; /** * Create and return instance. * * @return self */ public static function init(): Cart { if ( ! self::$instance ) { self::$instance = new self(); self::$instance->is_session = true; self::$instance->init_session(); } return self::$instance; } /** * Return the current session cart instance without triggering initialization. * * Unlike init(), this never builds a cart; it returns whatever singleton * already exists (or null). Useful while the cart is mid-initialization — * e.g. inside coupon validation fired from calculate_cart_totals() — when * StoreEngine->get_cart() has not yet received the return value of init(). * * @return ?Cart */ public static function get_instance(): ?Cart { return self::$instance; } public static function load_by_hash( string $hash, Customer $customer ): Cart { $self = new self(); $self->customer = $customer; $self->load_cart( $hash ); return $self; } private function __construct() { $this->fees = new CartFees(); } private function init_session() { // Cookie events - cart cookies need to be set before headers are sent. add_action( 'storeengine/cart/add_to_cart', [ $this, 'maybe_set_cart_cookies' ] ); add_action( 'wp', [ $this, 'maybe_set_cart_cookies' ], 99 ); add_action( 'shutdown', [ $this, 'maybe_set_cart_cookies' ], 0 ); add_action( 'wp_logout', [ $this, 'remove_cart_cookies' ] ); $this->cart_hash = Helper::get_cart_hash_from_cookie(); $this->cart_user_id = get_current_user_id(); $this->customer = StoreEngine::init()->get_customer(); add_action( 'storeengine/cart/loaded', [ $this, 'calculate_cart_totals' ], 20, 0 ); add_action( 'storeengine/cart/add_to_cart', [ $this, 'calculate_cart_totals' ], 20, 0 ); add_action( 'storeengine/cart/item_update_quantity', [ $this, 'calculate_cart_totals' ], 20, 0 ); add_action( 'storeengine/applied_coupon', [ $this, 'calculate_cart_totals' ], 20, 0 ); add_action( 'storeengine/removed_coupon', [ $this, 'calculate_cart_totals' ], 20, 0 ); add_action( 'storeengine/cart/item_removed', [ $this, 'calculate_cart_totals' ], 20, 0 ); add_action( 'storeengine/cart/item_restored', [ $this, 'calculate_cart_totals' ], 20, 0 ); add_action( 'storeengine/update_checkout', [ $this, 'calculate_cart_totals' ], 20, 0 ); add_action( 'wp_loaded', [ __CLASS__, 'handle_remove_cart_item_request' ], 20 ); add_action( 'wp_loaded', [ __CLASS__, 'handle_remove_coupon_request' ], 20 ); add_action( 'storeengine/cart/check_items', [ $this, 'validate_items' ] ); add_action( 'shutdown', [ $this, 'store_on_database' ], 100 ); // Load cart from db. $this->load_cart( $this->cart_hash, $this->cart_user_id ); } public function set_cart_hash( string $cart_hash ) { $this->cart_hash = $cart_hash; } private function load_cart( ?string $cart_hash = null, int $cart_user_id = 0 ) { if ( ! $cart_hash && ! $cart_user_id ) { return; } $carts = CartModel::get_carts_by_hash_or_user_id( $cart_hash, $cart_user_id ); if ( empty( $carts ) ) { return; } do_action_ref_array( 'storeengine/cart/loading_from_session', [ &$this, &$carts ] ); $cart = reset( $carts ); if ( ! $this->cart_hash ) { $this->cart_hash = $cart->cart_hash; } $this->cart_id = $cart->cart_id; $cart_data = maybe_unserialize( $cart->cart_data ); if ( ! is_array( $cart_data ) ) { // fallback for old cart. $cart_data = json_decode( $cart_data, true ); } $has_multiple = count( $carts ) > 1; /** @var CartItem[] $cart_items */ $cart_items = []; $cart_fees = []; $coupons = []; $meta = []; if ( ! empty( $cart_data ) ) { $cart_items = array_filter( $cart_data['items'] ?? [] ); $cart_fees = array_filter( $cart_data['fees'] ?? [] ); $coupons = $cart_data['coupons']; $meta = $cart_data['meta']; if ( ! empty( $cart_items ) && is_array( reset( $cart_items ) ) ) { // Backward compatibility. $cart_items = array_map( fn( $item ) => new CartItem( $item['key'], $item ), $cart_items ); // Remove unsupported price-type (item) if addon not active. $cart_items = array_filter( $cart_items, fn( $item ) => $this->is_price_type_allowed( $item->price_type ) ); } } if ( $has_multiple ) { $carts = $this->merge_carts( $carts ); $coupons = $carts['coupons']; $cart_items = $carts['items']; $cart_fees = $carts['fees']; $meta = $carts['meta']; $this->is_dirty = true; } if ( empty( $cart_items ) ) { return; } global $wpdb; foreach ( $cart_items as $key => $item ) { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $validate = $wpdb->get_var( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}storeengine_product_price WHERE id = %d AND product_id = %d;", $item->price_id, $item->product_id ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( ! $validate ) { continue; } $this->cart_items[ $key ] = $item; } if ( $cart_items !== $this->cart_items ) { // @TODO trigger notification for the user that invalid item is removed from cart. $this->is_dirty = true; } $coupons = array_unique( $coupons ); $this->applied_coupons = array_combine( $coupons, array_map( fn( $coupon ) => new Coupon( $coupon ), $coupons ) ); $this->meta = array_filter( $meta ); $this->fees->set_fees( $cart_fees ); if ( $this->is_session ) { do_action_ref_array( 'storeengine/cart/loaded_from_session', [ &$this ] ); } /** * Fires after cart loaded. */ do_action_ref_array( 'storeengine/cart/loaded', [ &$this ] ); } /** * Merge multiple carts. * * @param array $carts * * @return array { * @var CartItem $items * @var array $fees * @var array $coupons * @var array $meta * } */ private function merge_carts( array $carts ): array { $merged_items = []; $cart_coupons = []; $merged_fees = []; $cart_meta = []; foreach ( $carts as $cart ) { $cart_data = maybe_unserialize( $cart->cart_data ); if ( empty( $cart_data['items'] ) ) { // Empty cart data! continue; } $cart_items = array_values( array_filter( $cart_data['items'] ) ); $cart_coupons = array_merge( $cart_coupons, $cart_data['coupons'] ?? [] ); $merged_fees = array_merge( $merged_fees, $cart_data['fees'] ?? [] ); $cart_meta = array_merge( $cart_meta, $cart_data['meta'] ?? [] ); // Check items. foreach ( $cart_items as $cart_item ) { if ( is_array( $cart_item ) ) { // Backward compatibility. $cart_item = new CartItem( $cart_item['key'], $cart_item ); } $key = $cart_item->key; if ( ! $this->is_price_type_allowed( $cart_item->price_type ) ) { // Subscription & other item will be filtered out here if corresponding addon is not active. continue; } if ( ! isset( $merged_items[ $key ] ) ) { $has_same_product_id = array_filter( $merged_items, function ( $item ) use ( $cart_item ) { return $item->product_id === $cart_item->product_id && $item->price_id === $cart_item->price_id && $item->get_price() === $cart_item->get_price(); } ); if ( empty( $has_same_product_id ) ) { $merged_items[ $key ] = $cart_item; } } else { $merged_items[ $key ]->quantity += $cart_item->quantity; $merged_items[ $key ]->line_subtotal = (float) ( $merged_items[ $key ]->price * $merged_items[ $key ]->quantity ); } } } $rest_of_carts = array_slice( $carts, 1 ); $rest_of_carts_ids = wp_list_pluck( $rest_of_carts, 'cart_id' ); if ( ! empty( $rest_of_carts_ids ) ) { // @TODO Use wp list pluck -> $rest_of_carts, 'cart_hash'; // use cache delete multi with the cart hashes // @TODO use object cache for cart data. // Delete other rows. global $wpdb; // Prepare placeholder for cart ids. $placeholders = implode( ',', array_fill( 0, count( $rest_of_carts_ids ), ' %d' ) ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared above. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}storeengine_cart WHERE cart_id IN ($placeholders)", $rest_of_carts_ids ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared above. do_action( 'storeengine/cart/deleted', $rest_of_carts_ids ); } return [ 'items' => array_values( $merged_items ), 'fees' => array_unique( $merged_fees ), 'coupons' => array_unique( $cart_coupons ), // Meta is an associative key => value map (values may themselves be // arrays, e.g. chosen_shipping_methods). array_unique() casts each value // to a string for comparison — throwing "Array to string conversion" on // array values — and dedupes by value, which would silently drop a meta // key whose value matches another (e.g. has_subscription/has_trial both // true). array_merge() above already merges the map correctly. 'meta' => $cart_meta, ]; } public function validate_items() { $this->validate_cart_items(); $this->validate_coupon(); } private function validate_cart_items(): void { $cart_items = $this->cart_items; $price_ids = wp_list_pluck( $this->cart_items, 'price_id' ); /*$priceQuery = new PriceCollection( [ 'per_page' => -1, 'where' => [ 'key' => 'id', 'value' => $price_ids, 'compare' => 'in' ] ] );*/ $product_prices = PriceModel::get_pricing_with_products( $price_ids ); $missing_price_ids = array_diff( $price_ids, array_keys( $product_prices ) ); // Rebuild items. $new_cart_items = []; foreach ( $this->cart_items as $cart_item ) { if ( in_array( $cart_item->price_id, $missing_price_ids ) || ! isset( $product_prices[ $cart_item->price_id ] ) ) { // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict continue; } $product_price = $product_prices[ $cart_item->price_id ]; $price = (float) $product_price->price; $product_type = get_post_meta( $product_price->product_id, '_storeengine_product_type', true ); if ( 'variable' === $product_type && ( ! isset( $cart_item->variation_id ) || 0 === $cart_item->variation_id ) ) { continue; } if ( isset( $cart_item->variation_id ) && $cart_item->variation_id > 0 ) { $variation = Helper::get_product_variation( $cart_item->variation_id ); if ( ! $variation ) { continue; } $price += (float) $variation->get_price(); } $new_cart_items[ $cart_item->key ] = $cart_item->set_data( array_merge( [ 'name' => $product_price->post_title, 'product_type' => $product_type, 'price_name' => $product_price->price_name, 'price' => apply_filters('storeengine/product/get_price', $price, 'view' ), 'compare_price' => apply_filters('storeengine/product/get_compare_price', (float) $product_price->compare_price, 'view'), 'line_subtotal' => (float) ( $cart_item->quantity * $product_price->price ), ], $product_price->settings ) ); } $this->cart_items = apply_filters( 'storeengine/cart/validated_cart_items', $new_cart_items, $this ); $diff_with_new_cart_items = Helper::array_diff_recursive( $new_cart_items, $cart_items ); $this->is_dirty = count( $diff_with_new_cart_items ) > 0 || ( count( $new_cart_items ) !== count( $cart_items ) ); } private function validate_coupon() { foreach ( $this->applied_coupons as $coupon ) { if ( is_wp_error( $coupon->validate_coupon() ) ) { unset( $this->applied_coupons[ strtolower( $coupon->get_code() ) ] ); $this->is_dirty = true; } } } public function clear_cart() { // Trigger db-save. $this->is_dirty = true; // Reset values. $this->cart_items = []; $this->fees->remove_all_fees(); $this->shipping_methods = []; $this->coupon_discount_totals = []; $this->coupon_discount_tax_totals = []; $this->coupon_discount_per_item = []; $this->reward_units = []; $this->applied_coupons = []; $this->totals = $this->default_totals; $this->meta = []; } /** * Remove coupons from the cart of a defined type. Type 1 is before tax, type 2 is after tax. */ public function remove_coupons() { $this->set_coupon_discount_totals(); $this->set_coupon_discount_tax_totals(); $this->set_coupon_discount_per_item(); $this->set_reward_units(); $this->set_applied_coupons(); $this->is_dirty = true; } /** * Generate a unique ID for the cart item being added. * * @param int $price_id - id of the product the key is being generated for. * @param int $product_id - id of the product the key is being generated for. * @param int|float $amount - id of the product the key is being generated for. * @param int $variation_id of the product the key is being generated for. * @param array $variation data for the cart item. * @param array $item_data other cart item data passed which affects this items uniqueness in the cart. * * @return string cart item key */ public function generate_cart_item_id( int $price_id, int $product_id, $amount = 0, int $variation_id = 0, array $variation = [], array $item_data = [] ): string { $id_parts = [ $price_id, $product_id, $amount ]; if ( $variation_id ) { $id_parts[] = $variation_id; } if ( ! empty( $variation ) ) { $variation_key = ''; foreach ( $variation as $key => $value ) { $variation_key .= trim( $key ) . trim( $value ); } $id_parts[] = $variation_key; } if ( ! empty( $item_data ) ) { $item_data_key = ''; foreach ( $item_data as $key => $value ) { if ( is_array( $value ) || is_object( $value ) ) { $value = http_build_query( $value ); } $item_data_key .= trim( $key ) . trim( $value ); } $id_parts[] = $item_data_key; } $cart_item_id = md5( implode( '_', $id_parts ) ); /** * Filter cart item id after generation. * * @param string $cart_item_id Cart item id. * @param int $product_id Product id. * @param int $variation_id Variation id. * @param array $item_data Item data. */ return apply_filters( 'storeengine/cart/item_id', $cart_item_id, $product_id, $variation_id, $variation, $item_data ); } /** * Get allowed price types. * @return array */ public function get_allowed_price_types(): array { /** * Allowed Price type filter. * * @param string[] $price_types Allowed price type to be processed. * * @since 1.6.9 */ return apply_filters( 'storeengine/cart/allowed_price_types', [ 'onetime' ] ); } public function is_price_type_allowed( string $price_type, $context = 'add-to-cart' ): bool { return in_array( $price_type, $this->get_allowed_price_types(), true ); } /** * @param int $price_id * @param int $quantity * @param int $variation_id * @param array $variation * @param array $item_data * * @return true|WP_Error */ public function add_product_to_cart( int $price_id, int $quantity = 1, int $variation_id = 0, array $variation = [], array $item_data = [] ) { try { $quantity = absint( $quantity ); $price = new Price( $price_id ); $price_id = $price->get_id(); $product = $price->get_product(); $product_id = $product ? $product->get_id() : 0; if ( ! $quantity ) { return new WP_Error( 'invalid-qty', __( 'Invalid quantity!', 'storeengine' ), [ 'status' => 400 ] ); } if ( ! $price->get_id() ) { return new WP_Error( 'invalid-price-id', __( 'Invalid price id!', 'storeengine' ), [ 'status' => 400 ] ); } if ( ! $product ) { return new WP_Error( 'product-not-exists', __( 'This product is no longer available!', 'storeengine' ), [ 'status' => 400 ] ); } if ( in_array( get_post_status( $product_id ), [ 'trash', 'draft', 'auto-draft' ], true ) ) { return new WP_Error( 'product-in-trash', __( 'This product is no longer available!', 'storeengine' ), [ 'status' => 422 ] ); } if ( ! $this->is_price_type_allowed( $price->get_price_type() ) ) { return new WP_Error( 'price-type-not-allowed', __( 'Product price not allowed or no longer available.', 'storeengine' ), [ 'status' => 400, 'price_type' => $price->get_price_type() ] ); } if ( $price->is_subscription() && ! Helper::get_addon_active_status( 'subscription' ) ) { // @Note No longer necessary, deprecated should be removed. // This is no longer necessary for the new allowed-price-types filter. return new WP_Error( 'subscription-addon-not-enabled', __( 'Subscription addon is not enable.', 'storeengine' ), [ 'status' => 400 ] ); } do_action_ref_array( 'storeengine/cart/before_add_to_cart', [ &$this, $price, $quantity, $variation_id, $variation, $item_data ] ); $amount = $price->get_price(); if ( 0 < $variation_id ) { $variation_obj = Helper::get_product_variation( $variation_id ); if ( ! $variation_obj ) { return new WP_Error( 'invalid-product-variation', __( 'Invalid variation selected!', 'storeengine' ) ); } $amount = $price->get_price() + (float) $variation_obj->get_price(); } $existing_qty = 0; foreach ( $this->cart_items as $existing_item ) { if ( (int) $existing_item->product_id === (int) $product_id && (int) $existing_item->variation_id === (int) $variation_id ) { $existing_qty += (int) $existing_item->quantity; } } if ( method_exists( $product, 'is_sold_individually' ) && $product->is_sold_individually() && ( $existing_qty + $quantity ) > 1 ) { return new WP_Error( 'sold-individually', __( 'Only one of this item can be added to the cart.', 'storeengine' ), [ 'status' => 400 ] ); } $stock_target = null; if ( 0 < $variation_id && isset( $variation_obj ) && method_exists( $variation_obj, 'manages_stock' ) && $variation_obj->manages_stock() ) { $stock_target = $variation_obj; } elseif ( method_exists( $product, 'manages_stock' ) ) { $stock_target = $product; } if ( $stock_target ) { if ( method_exists( $stock_target, 'is_in_stock' ) && ! $stock_target->is_in_stock() ) { return new WP_Error( 'out-of-stock', __( 'This product is out of stock.', 'storeengine' ), [ 'status' => 400 ] ); } if ( method_exists( $stock_target, 'has_enough_stock' ) && ! $stock_target->has_enough_stock( $existing_qty + $quantity ) ) { return new WP_Error( 'not-enough-stock', __( 'Not enough stock available for the requested quantity.', 'storeengine' ), [ 'status' => 400 ] ); } } /** * Filter cart item data during add to cart. * * @param array $item_data Item data. * @param Price $price Price. * @param int $product_id Product id. * @param int $variation_id Variation id. * @param int $quantity Quantity. */ $item_data = (array) apply_filters( 'storeengine/cart/add_item_data', $item_data, $price, $product_id, $variation_id, $quantity ); // Generate a ID based on product ID, variation ID, variation data, and other cart item data. // Use price id to allow multi price per product in cart. // Use amount to allow custom price based same product in cart. $cart_id = $this->generate_cart_item_id( 0, //$price->get_id(), $price->get_product_id(), 0, //$amount, $variation_id, $variation, $item_data ); // Find the cart item key in the existing cart. $cart_item_key = $this->find_product_in_cart( $cart_id ); if ( $cart_item_key ) { $old_quantity = (int) $this->cart_items[ $cart_item_key ]->quantity; $new_quantity = $old_quantity + $quantity; // Remove this condition to allow multi price per product in cart. if ( $this->cart_items[ $cart_item_key ]->price_id === $price->get_id() && $new_quantity ) { // Update qty & price. $this->cart_items[ $cart_item_key ]->quantity = $new_quantity; $this->cart_items[ $cart_item_key ]->line_subtotal = (float) ( $price->get_price() * $new_quantity ); do_action( 'storeengine/cart/after_item_quantity_update', $this->cart_items[ $cart_item_key ], $old_quantity, $this ); // Trigger database update. $this->is_dirty = true; return true; } else { // Also, remove this to allow multi price per product in cart. $this->remove_cart_item( $cart_item_key ); } } $cart_item_data = array_merge( [ 'key' => $cart_id, 'product_id' => $price->get_product_id(), 'product_type' => $product->get_type(), 'taxable' => $price->is_taxable(), 'variation_id' => $variation_id, 'variation' => $variation, 'price_id' => $price->get_id(), 'name' => $price->get_post_title(), 'price_name' => $price->get_price_name(), 'price_type' => $price->get_price_type(), 'price' => $amount, 'compare_price' => $price->get_compare_price(), 'quantity' => $quantity, 'line_subtotal' => $amount * $quantity, 'item_data' => $item_data, ], $price->get_settings(), ); if ( 'bundled' === $product->get_type() ) { $cart_item_data['bundles'] = $product->get_bundles(); } $cart_item = new CartItem( $cart_id, $cart_item_data ); /** * Filter cart item data after add to cart. * * @param CartItem $cart_item_data Cart item data. * @param string $cart_id Cart item id. */ $this->cart_items[ $cart_id ] = apply_filters( 'storeengine/cart/add_item', $cart_item, $cart_id ); if ( ! $price->is_subscription() && $price->get_setup_fee() ) { $feeId = sanitize_title( sprintf( '%s-%s-%s', $product_id, $price_id, $price->get_setup_fee_name() ) ); $this->add_fee( $price->get_setup_fee_name(), $price->get_setup_fee_price(), $feeId ); } // Trigger db update before user tapping into the data. $this->is_dirty = true; /** * Fires after adding product on Cart. * * @param string $cart_id Cart id. * @param int $price_id Price id. * @param int $product_id Product id. * @param int $variation_id Variation id. * @param int $quantity Quantity. * @param array $item_data Item data. */ do_action( 'storeengine/cart/add_to_cart', $cart_id, $price_id, $product_id, $variation_id, $quantity, $item_data ); return true; } catch ( StoreEngineException $e ) { Helper::log_error( $e ); return $e->get_wp_error(); } } /** * Validate every line item against current stock. Returns a list of WP_Error * objects (empty when the cart is fine). * * @return WP_Error[] */ public function check_cart_items(): array { $errors = []; foreach ( $this->cart_items as $item ) { $qty = (int) ( $item->quantity ?? 0 ); $product_id = (int) ( $item->product_id ?? 0 ); $variation_id = (int) ( $item->variation_id ?? 0 ); $target = null; if ( $variation_id ) { $variation_obj = Helper::get_product_variation( $variation_id ); if ( $variation_obj && method_exists( $variation_obj, 'manages_stock' ) && $variation_obj->manages_stock() ) { $target = $variation_obj; } } if ( ! $target && $product_id ) { $product = Helper::get_product( $product_id ); if ( $product ) { $target = $product; } } if ( ! $target ) { continue; } if ( method_exists( $target, 'is_in_stock' ) && ! $target->is_in_stock() ) { $errors[] = new WP_Error( 'out-of-stock', sprintf( /* translators: %s: product name */ __( '"%s" is out of stock.', 'storeengine' ), $item->name ?? '' ), [ 'product_id' => $product_id, 'variation_id' => $variation_id ] ); continue; } if ( method_exists( $target, 'has_enough_stock' ) && ! $target->has_enough_stock( $qty ) ) { $errors[] = new WP_Error( 'not-enough-stock', sprintf( /* translators: %s: product name */ __( 'Not enough stock for "%s".', 'storeengine' ), $item->name ?? '' ), [ 'product_id' => $product_id, 'variation_id' => $variation_id ] ); } } return $errors; } public function update_quantity( string $item_key, int $quantity = 1 ) { if ( 0 === $quantity || $quantity < 0 ) { // If we're setting qty to 0 we're removing the item from the cart. if ( $this->remove_cart_item( $item_key ) ) { return true; } } if ( ! $this->item_exists( $item_key ) ) { return new WP_Error( 'item-not-found', __( 'Item not found!', 'storeengine' ), [ 'status' => 404 ] ); } if ( 'subscription' === $this->cart_items[ $item_key ]->price_type ) { return new WP_Error( 'quantity-update-failed-for-subscription', __( 'Cannot update subscription quantity!', 'storeengine' ), [ 'status' => 404 ] ); } $cart_item = $this->cart_items[ $item_key ]; $variation_id = (int) ( $cart_item->variation_id ?? 0 ); $product_id = (int) ( $cart_item->product_id ?? 0 ); $stock_target = null; if ( $variation_id ) { $variation_obj = Helper::get_product_variation( $variation_id ); if ( $variation_obj && method_exists( $variation_obj, 'manages_stock' ) && $variation_obj->manages_stock() ) { $stock_target = $variation_obj; } } if ( ! $stock_target && $product_id ) { $product = Helper::get_product( $product_id ); if ( $product && method_exists( $product, 'manages_stock' ) ) { $stock_target = $product; } } if ( $stock_target && method_exists( $stock_target, 'has_enough_stock' ) && ! $stock_target->has_enough_stock( $quantity ) ) { return new WP_Error( 'not-enough-stock', __( 'Not enough stock available for the requested quantity.', 'storeengine' ), [ 'status' => 400 ] ); } if ( $stock_target && method_exists( $stock_target, 'is_sold_individually' ) && $stock_target->is_sold_individually() && $quantity > 1 ) { return new WP_Error( 'sold-individually', __( 'Only one of this item can be added to the cart.', 'storeengine' ), [ 'status' => 400 ] ); } do_action( 'storeengine/cart/after_item_quantity_update', $this->cart_items[ $item_key ], $this->cart_items[ $item_key ]->quantity, $this ); // Update qty. $this->cart_items[ $item_key ]->quantity = $quantity; $this->is_dirty = true; /** * Fires after updating cart item quantity. * * @param string $item_key Cart item key. * @param int $quantity Cart item quantity. * @param Cart $this Cart instance. */ do_action( 'storeengine/cart/item_update_quantity', $item_key, $quantity, $this ); return true; } /** * Returns an array of cart line items. * * @return CartItem[] */ public function get_cart_items(): array { return $this->cart_items; } public function has_items(): bool { return ! empty( array_filter( $this->cart_items ) ); } public function item_exists( string $item_key ): bool { return isset( $this->cart_items[ $item_key ] ); } public function get_cart_item( string $item_key ): ?CartItem { return $this->cart_items[ $item_key ] ?? null; } public function remove_cart_item( string $item_key ): bool { if ( isset( $this->cart_items[ $item_key ] ) ) { // Trigger db update. $this->is_dirty = true; /** * Fires before removing cart item. * * @param string $item_key Cart item key. * @param Cart $this Cart instance. */ do_action_ref_array( 'storeengine/cart/remove_item', [ &$this, $item_key ] ); $cart_item = $this->cart_items[ $item_key ]; if ( isset( $cart_item->setup_fee ) && $cart_item->setup_fee ) { $this->fees->remove_fee( sanitize_title( sprintf( '%s-%s-%s', $cart_item->product_id, $cart_item->price_id, $cart_item->setup_fee_name ) ) ); } unset( $this->cart_items[ $item_key ] ); $this->is_cart_consist_subscription_product(); /** * Fires after removing cart item. * * @param string $item_key Cart item key. * @param Cart $this Cart instance. */ do_action_ref_array( 'storeengine/cart/item_removed', [ &$this, $item_key ] ); return true; } return false; } public function get_items_count(): int { return count( $this->cart_items ); } public function get_count(): int { $count = 0; foreach ( $this->cart_items as $cart_item ) { $count += $cart_item->quantity; } return $count; } public function get_cart_item_by_product( int $product_id, int $price_id = null ): ?CartItem { foreach ( $this->cart_items as $cart_item ) { if ( $product_id && $price_id ) { if ( (int) $cart_item->product_id === $product_id && (int) $cart_item->price_id === $price_id ) { return $cart_item; } continue; } if ( (int) $cart_item->product_id === $product_id ) { return $cart_item; } } return null; } /** * @param int $product_id * * @return CartItem[] */ public function get_cart_items_by_product( int $product_id ): array { return array_filter( $this->cart_items, function ( $cart_item ) use ( $product_id ) { return $cart_item->product_id === $product_id; } ); } /** * Trigger an action so 3rd parties can add custom fees. */ public function calculate_fees() { do_action( 'storeengine/cart/calculate_fees', $this ); } /** * Return reference to fees API. * * @return CartFees */ public function fees_api(): ?CartFees { return $this->fees; } /** * Add additional fee to the cart. * * This method should be called on a callback attached to the * cart fee-calculation hook during cart/checkout. Fees do not * persist. * * @param string $name Unique name for the fee. Multiple fees of the same name cannot be added. * @param string|int|float $amount Fee amount (do not enter negative amounts). * @param string $id Unique id for the fee. Multiple fees of the id name cannot be added. * @param bool $taxable Is the fee taxable? (default: false). * @param string $tax_class The tax class for the fee if taxable. A blank string is standard tax class. (default: ''). * * @uses the cart-fees API to add a fee. */ public function add_fee( string $name, $amount, string $id = '', bool $taxable = true, string $tax_class = '' ) { $this->fees_api()->add_fee( [ 'id' => $id, 'name' => $name, 'amount' => (float) $amount, 'taxable' => $taxable, 'tax_class' => $tax_class, ] ); } /** * Return all added fees from the Fees API. * * @return array * @uses CartFees::get_fees */ public function get_fees(): array { return $this->fees_api()->get_fees(); } /** * Gets the sub-total (after calculation). * * @param bool $compound whether to include compound taxes. * * @return string formatted price */ public function get_cart_subtotal( bool $compound = false ): string { /** * If the cart has compound tax, we want to show the subtotal as cart + shipping + non-compound taxes (after discount). */ if ( $compound ) { $cart_subtotal = Formatting::price( $this->get_cart_contents_total() + $this->get_shipping_total() + $this->get_taxes_total( false, false ) ); } elseif ( $this->display_prices_including_tax() ) { $cart_subtotal = Formatting::price( $this->get_subtotal() + $this->get_subtotal_tax() ); if ( $this->get_subtotal_tax() > 0 && ! TaxUtil::prices_include_tax() ) { $cart_subtotal .= ' ' . Countries::init()->inc_tax_or_vat() . ''; } } else { $cart_subtotal = Formatting::price( $this->get_subtotal() ); if ( $this->get_subtotal_tax() > 0 && TaxUtil::prices_include_tax() ) { $cart_subtotal .= ' ' . Countries::init()->ex_tax_or_vat() . ''; } } /** * Filter cart subtotal. * * @param string $cart_subtotal Cart subtotal. * @param int $compound Compound. * @param Cart $this Cart instance. */ return apply_filters( 'storeengine/cart/subtotal', $cart_subtotal, $compound, $this ); } /** * Get the product row price per item. * * @param float $price Price. * @param ?int $priceId Price ID. * @param ?int $product Product ID. * * @return string formatted price * @throws StoreEngineException */ public function get_product_price( float $price, ?int $priceId = null, ?int $product = null ): string { if ( $this->display_prices_including_tax() ) { $product_price = Formatting::get_price_including_tax( $price, $priceId, $product ); } else { $product_price = Formatting::get_price_excluding_tax( $price, $priceId, $product ); } $formatted_price = Formatting::price( $product_price ); /** * Filter the formatted product price from price(float). * * @param string $formatted_price Formatted price. * @param Price $price Price. * @param ?int $priceId Price ID. * @param ?int $product Product ID. */ return apply_filters( 'storeengine/cart/product_price', $formatted_price, Helper::get_price( $priceId ), $priceId, $product ); } /** * Get the product row subtotal. * * Gets the tax etc. to avoid rounding issues. * * When on the checkout (review order), this will get the subtotal based on the customer's tax rate rather than the base rate. * * @param float $price Product object. * @param int $price_id * @param int $product Product object. * @param int $quantity Quantity being purchased. * @param bool $taxable * * @return string formatted price * @throws StoreEngineException */ public function get_product_subtotal( float $price, int $price_id, int $product, int $quantity, bool $taxable = true ): string { if ( TaxUtil::is_tax_enabled() && $taxable ) { if ( $this->display_prices_including_tax() ) { $product_subtotal = Formatting::price( Formatting::get_price_including_tax( $price, null, $product, [ 'qty' => $quantity, 'taxable' => $taxable, ] ) ); if ( ! TaxUtil::prices_include_tax() && $this->get_subtotal_tax() > 0 ) { $product_subtotal .= ' ' . Countries::init()->inc_tax_or_vat() . ''; } } else { $product_subtotal = Formatting::price( Formatting::get_price_excluding_tax( $price, null, $product, [ 'qty' => $quantity, 'taxable' => $taxable, ] ) ); if ( TaxUtil::prices_include_tax() && $this->get_subtotal_tax() > 0 ) { $product_subtotal .= ' ' . Countries::init()->ex_tax_or_vat() . ''; } } } else { $row_price = $price * $quantity; $product_subtotal = Formatting::price( $row_price ); } /** * Filter formatted product subtotal from price(float). * * @param String $product_subtotal Formatted product subtotal. * @param int $product Product ID. * @param int $quantity Quantity. * @param Cart $this Cart instance. */ return (string) apply_filters( 'storeengine/cart/product_subtotal', $product_subtotal, Helper::get_price( $price_id ), $quantity, $this ); } public function get_total_discount() { $total = 0; foreach ( $this->get_coupons() as $coupon ) { $total += $this->get_coupon_discount_amount( $coupon->get_code(), $this->display_prices_including_tax() ); } return $total; } /** * Get the discount amount for a used coupon. * * @param string $code coupon code. * @param bool $ex_tax inc or ex tax. * * @return float discount amount */ public function get_coupon_discount_amount( string $code, bool $ex_tax = true ): float { // Discount totals are keyed by the lowercased coupon code (see Discounts and // apply_coupon, which both use strtolower()). Normalize the lookup so a coupon // whose code contains uppercase letters still resolves its discount instead of // rendering -$0.00 in the totals. $code = strtolower( $code ); $discount_amount = $this->coupon_discount_totals[ $code ] ?? 0; if ( ! $ex_tax ) { $discount_amount += $this->get_coupon_discount_tax_amount( $code ); } return Formatting::round_discount( $discount_amount, Formatting::get_price_decimals() ); } /** * Get the discount tax amount for a used coupon (for tax inclusive prices). * * @param string $code coupon code. * * @return float discount amount */ public function get_coupon_discount_tax_amount( string $code ): float { // Keyed by lowercased coupon code — normalize to match (see get_coupon_discount_amount). $code = strtolower( $code ); return Formatting::round_discount( $this->coupon_discount_tax_totals[ $code ] ?? 0, Formatting::get_price_decimals() ); } public function apply_coupon( $coupon_code ) { // Check if already applied to the cart. if ( $this->is_coupon_applied( $coupon_code ) ) { return new WP_Error( 'coupon_already_applied', sprintf( // translators: %s: Coupon code. esc_html__( 'Coupon code "%s" has already been applied.', 'storeengine' ), esc_html( $coupon_code ) ) ); } // Load & validate coupon. $coupon = new Coupon( $coupon_code ); $is_valid = $coupon->validate_coupon(); if ( is_wp_error( $is_valid ) ) { return $is_valid; } // Store. $this->applied_coupons[ strtolower( $coupon->code ) ] = $coupon; // Trigger save. $this->is_dirty = true; /** * Fires after applied coupon on cart. */ do_action( 'storeengine/applied_coupon' ); return true; } public function remove_coupon( $coupon_code ) { $coupon_code = strtolower( $coupon_code ); if ( isset( $this->applied_coupons[ $coupon_code ] ) ) { unset( $this->applied_coupons[ $coupon_code ] ); $this->is_dirty = true; /** * Fires after removing coupon from cart. */ do_action( 'storeengine/removed_coupon' ); return true; } return new WP_Error( 'not_found_coupon', __( 'Coupon not found!', 'storeengine' ) ); } /** * @return Coupon[] */ public function get_coupons(): array { return $this->applied_coupons; } public function is_coupon_applied( $coupon_code ): bool { return isset( $this->applied_coupons[ strtolower( $coupon_code ) ] ); } public function calculate_cart_totals() { $this->reset_totals(); if ( ! count( $this->cart_items ) && ! count( $this->get_fees() ) ) { return; } /** * Fires before calculating cart totals. * * @param Cart $this Cart instance. */ do_action( 'storeengine/cart/before_calculate_totals', $this ); new CartTotals( $this ); /** * Fires after calculating cart totals. * * @param Cart $this Cart instance. */ do_action( 'storeengine/cart/after_calculate_totals', $this ); } public function calculate_totals() { $this->calculate_cart_totals(); } /** * Looks at the totals to see if payment is actually required. * * @return bool */ public function needs_payment(): bool { $needs_payment = 0 < $this->get_total( 'edit' ); /** * Filter cart needs payment. * * @param bool $needs_payment Payment needed or not. * @param Cart $this Cart instance. */ return apply_filters( 'storeengine/cart/needs_payment', $needs_payment, $this ); } /** * Get selected shipping methods after calculation. * * @return StoreEngine\Shipping\ShippingRate[] */ public function get_shipping_methods(): array { return $this->shipping_methods; } /** * Whether the shipping totals have been calculated. * * @return bool */ public function has_calculated_shipping(): bool { return $this->has_calculated_shipping; } /** * Looks through the cart to see if shipping is actually required. * * @return bool whether the cart needs shipping */ public function needs_shipping(): bool { if ( ! ShippingUtils::is_shipping_enabled() || 0 === ShippingUtils::get_shipping_methods_count() ) { return false; } $needs_shipping = false; foreach ( $this->get_cart_items() as $cart_item ) { $product = Helper::get_product( $cart_item->product_id ); if ( $product && $product->needs_shipping() ) { $needs_shipping = true; break; } } return apply_filters( 'storeengine/cart/needs_shipping', $needs_shipping ); } /** * Sees if the customer has entered enough data to calculate shipping. * * @return bool */ public function show_shipping(): bool { // If there are no shipping methods or no cart contents, no need to calculate shipping. if ( ! ShippingUtils::is_shipping_enabled() || 0 === ShippingUtils::get_shipping_methods_count( true ) || ! $this->get_count() ) { return false; } if ( Helper::get_settings( 'storeengine/shipping/cost_requires_address', true ) ) { $customer = $this->get_customer(); if ( ! $customer instanceof Customer || ! $customer->has_full_shipping_address() ) { return false; } } /** * Filter to allow plugins to prevent shipping calculations. * * @param bool $ready Whether the cart is ready to calculate shipping. */ return apply_filters( 'storeengine/cart/ready_to_calc_shipping', true ); } public function is_product_in_cart( $product_id ) { foreach ( $this->cart_items as $item_key => $cart_item ) { if ( $cart_item->product_id === $product_id ) { return $item_key; } } return ''; } public function is_price_in_cart( $price_id ) { foreach ( $this->cart_items as $item_key => $cart_item ) { if ( (int) $cart_item->price_id === $price_id ) { return $item_key; } } return ''; } public function has_onetime_products(): bool { $has = false; foreach ( $this->cart_items as $cart_item ) { if ( 'onetime' === $cart_item->price_type ) { $has = true; break; } } return $has; } /** * Check if product is in the cart and return cart item key. * * Cart item key will be unique based on the item and its properties, such as variations. * * @param string|bool $cart_id id of product to find in the cart. * * @return string cart item key */ public function find_product_in_cart( $cart_id = false ): string { if ( false !== $cart_id ) { if ( isset( $this->cart_items[ $cart_id ] ) ) { return $cart_id; } } return ''; } public function is_cart_consist_subscription_product(): bool { $has_subscription = false; $has_trial = false; $active_addon = Helper::get_addon_active_status( 'subscription' ); foreach ( $this->cart_items as $cart_item ) { if ( 'subscription' === $cart_item->price_type && $active_addon ) { $has_subscription = true; $has_trial = $cart_item->trial && $cart_item->trial_days > 0; break; } } $old_has_sub = $this->meta['has_subscription'] ?? false; $old_has_trial = $this->meta['has_trial'] ?? false; $this->meta['has_subscription'] = $has_subscription; $this->meta['has_trial'] = $has_trial; if ( ( ! $old_has_sub && $has_subscription ) || ( ! $old_has_trial && $has_trial ) ) { $this->is_dirty = true; } return $has_subscription; } /** * Set multiple meta data together. * * @param array $metadata Meta data. * * @return void */ public function set_meta_multiple( array $metadata ) { foreach ( $metadata as $meta_key => $value ) { $this->set_meta( $meta_key, $value ); } } /** * Add/set cart meta data. * * @param string $key Meta key. * @param mixed $value Value to store, any serializable value. * * @return void */ public function set_meta( string $key, $value ) { if ( array_key_exists( $key, $this->meta ) && $value === $this->meta[ $key ] ) { return; } $this->meta[ $key ] = $value; $this->is_dirty = true; } /** * Remove cart metadata. * * @param string|string[] $keys Meta key. * * @return void */ public function remove_meta( $keys ) { if ( is_string( $keys ) ) { $keys = array_map( 'trim', explode( ',', $keys ) ); } foreach ( $keys as $key ) { if ( array_key_exists( $key, $this->meta ) ) { unset( $this->meta[ $key ] ); $this->is_dirty = true; } } } /** * Get cart metadata. * * @param string $key Meta key. * * @return mixed|null meta value if exists or null. */ public function get_meta( string $key ) { return $this->meta[ $key ] ?? null; } public function get_meta_data(): array { return $this->meta; } /** * Checks if cart has subscription. * * @return bool * @deprecated */ public function has_subscription_product(): bool { return Helper::get_addon_active_status( 'subscription' ) && $this->get_meta( 'has_subscription' ); } public function is_cart_empty(): bool { return 0 === count( $this->cart_items ); } public function store_on_database() { if ( self::$instance !== $this || ! $this->is_session || ! $this->is_dirty ) { return; } try { $this->calculate_cart_totals(); } catch ( \Throwable $e ) { Helper::log_error( $e ); } $data = [ 'items' => $this->cart_items, 'fees' => $this->get_fees(), 'coupons' => ! empty( $this->cart_items ) ? array_keys( $this->applied_coupons ) : [], 'totals' => $this->totals, 'meta' => $this->meta, ]; if ( $this->cart_id ) { CartModel::update( $this->cart_id, $this->cart_hash, $data ); } else { $this->cart_id = CartModel::create( $this->cart_user_id, $this->cart_hash, $data ); } do_action_ref_array( 'storeengine/cart/saved', [ &$this ] ); $this->is_dirty = false; } public function get_cart_hash(): string { return $this->cart_hash; } public function get_cart_id(): string { return $this->cart_id; } /** * Get packages to calculate shipping for. * * This lets us calculate costs for carts that are shipped to multiple locations. * * Shipping methods are responsible for looping through these packages. * * By default, we pass the cart itself as a package - plugins can change this. * through the filter and break it up. * * @return array of cart items */ public function get_shipping_packages(): array { return apply_filters( 'storeengine/cart/shipping_packages', [ [ 'contents' => $this->get_items_needing_shipping(), 'contents_cost' => array_sum( wp_list_pluck( $this->get_items_needing_shipping(), 'line_total' ) ), 'applied_coupons' => $this->get_coupons(), 'user' => [ 'ID' => get_current_user_id(), ], 'destination' => [ 'country' => $this->get_customer()->get_shipping_country(), 'state' => $this->get_customer()->get_shipping_state(), 'postcode' => $this->get_customer()->get_shipping_postcode(), 'city' => $this->get_customer()->get_shipping_city(), 'address_1' => $this->get_customer()->get_shipping_address_1(), 'address_2' => $this->get_customer()->get_shipping_address_2(), ], 'cart_subtotal' => $this->get_displayed_subtotal(), ], ], $this ); } /** * Get only items that need shipping. * * @return CartItem[] */ protected function get_items_needing_shipping(): array { return array_filter( $this->get_cart_items(), [ $this, 'filter_items_needing_shipping' ] ); } /** * Filter items needing shipping callback. * * @param CartItem $item Item to check for shipping. * * @return bool */ protected function filter_items_needing_shipping( CartItem $item ): bool { $product = Helper::get_product( $item->product_id ); return $product && $product->needs_shipping(); } /** * Returns 'incl' if tax should be included in cart, otherwise returns 'excl'. * * @return string */ public function get_tax_price_display_mode(): string { return TaxUtil::get_tax_price_display_mode( $this->get_customer() ); } /** * Return whether-or-not the cart is displaying prices including tax, rather than excluding tax. * * @return bool */ public function display_prices_including_tax(): bool { /** * Filtering if display prices including tax or not! * * @param bool $prices_including_tax True / False. */ return apply_filters_deprecated( 'storeengine/cart/display_prices_including_tax', [ TaxUtil::display_prices_including_tax( $this->get_customer() ) ], '1.6.4', 'storeengine/tax/display_prices_including_tax' ); } /** * Return whether-or-not the cart is displaying prices including tax, rather than excluding tax. * * @return bool */ public function display_prices_excluding_tax(): bool { return TaxUtil::display_prices_excluding_tax( $this->get_customer() ); } /** * Get cart's owner. * * @return Customer|null */ public function get_customer(): ?Customer { return $this->customer; } /** * Reset cart totals to the defaults. Useful before running calculations. */ private function reset_totals() { $this->totals = $this->default_totals; /** * Fires after reset the cart. * * @param Cart $this Cart instance. */ do_action( 'storeengine/cart/reset', $this ); } /** * @param string $item_key * * @return string */ public static function get_remove_item_url( string $item_key ): string { $cart_page_url = Helper::get_page_permalink( 'cart_page' ); $remove_item_url = $cart_page_url ? wp_nonce_url( add_query_arg( 'remove_item', $item_key, $cart_page_url ), 'storeengine/cart' ) : ''; /** * Filter cart item remove url. * * @param string $remove_item_url The remove of a cart item. * * @returns string Remove item url */ return apply_filters( 'storeengine/cart/get_remove_url', $remove_item_url ); } public static function handle_remove_cart_item_request(): void { if ( isset( $_GET['remove_item'], $_REQUEST['_wpnonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ), 'storeengine/cart' ) ) { StoreEngine\Utils\Caching::nocache_headers(); $item_key = sanitize_text_field( wp_unslash( $_GET['remove_item'] ) ); $item = self::init()->get_cart_item( $item_key ); if ( $item ) { self::init()->remove_cart_item( $item_key ); } if ( wp_get_referer() ) { $remove = [ 'remove_item', 'add-to-cart', 'added-to-cart', 'order_again', '_wpnonce', ]; wp_safe_redirect( remove_query_arg( $remove, add_query_arg( 'removed_item', '1', wp_get_referer() ) ) ); exit; } } } public static function handle_remove_coupon_request(): void { if ( isset( $_GET['remove_coupon'], $_GET['_wpnonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'storeengine/cart/remove_coupon' ) ) { StoreEngine\Utils\Caching::nocache_headers(); $coupon_code = sanitize_text_field( urldecode( wp_unslash( $_GET['remove_coupon'] ) ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( $coupon_code ) { self::init()->remove_coupon( $coupon_code ); } if ( wp_get_referer() ) { wp_safe_redirect( remove_query_arg( [ 'remove_coupon', '_wpnonce' ], wp_get_referer() ) ); exit; } } } // Totals /** * Sets the array of calculated coupon totals. * * @param array $value Value to set. */ public function set_coupon_discount_totals( array $value = [] ) { $this->coupon_discount_totals = $value; } /** * Sets the array of calculated coupon tax totals. * * @param array $value Value to set. */ public function set_coupon_discount_tax_totals( array $value = [] ) { $this->coupon_discount_tax_totals = $value; } /** * Sets the per-item coupon discount breakdown. * * @param array $value Item Key => Coupon Code => discount amount. */ public function set_coupon_discount_per_item( array $value = [] ) { $this->coupon_discount_per_item = $value; } /** * Sets the rewarded ("free") unit counts. * * @param array $value Coupon Code => Item Key => units. */ public function set_reward_units( array $value = [] ) { $this->reward_units = $value; } /** * @deprecated Use set_reward_units(). * * @param array $value Coupon Code => Item Key => units. */ public function set_bogo_rewards( array $value = [] ) { $this->set_reward_units( $value ); } /** * Sets the array of applied coupon codes. * * @param array $value List of applied coupon codes. */ public function set_applied_coupons( array $value = [] ) { $this->applied_coupons = $value; } /** * Set all calculated totals. * * @param array $value Value to set. */ public function set_totals( array $value = [] ) { $this->totals = wp_parse_args( $value, $this->default_totals ); } /** * Set subtotal. * * @param float|string $value Value to set. */ public function set_subtotal( $value ) { $this->totals['subtotal'] = Formatting::format_decimal( $value ); } /** * Set subtotal. * * @param float $value Value to set. */ public function set_subtotal_tax( float $value ) { $this->totals['subtotal_tax'] = $value; } /** * Set discount_total. * * @param float $value Value to set. */ public function set_discount_total( float $value ) { $this->totals['discount_total'] = $value; } /** * Set discount_tax. * * @param float $value Value to set. */ public function set_discount_tax( float $value ) { $this->totals['discount_tax'] = $value; } /** * Set shipping_total. * * @param float $value Value to set. */ public function set_shipping_total( float $value ) { $this->totals['shipping_total'] = Formatting::format_decimal( $value ); } /** * Set shipping_tax. * * @param float $value Value to set. */ public function set_shipping_tax( float $value ) { $this->totals['shipping_tax'] = $value; } /** * Set cart_contents_total. * * @param float $value Value to set. */ public function set_cart_contents_total( float $value ) { $this->totals['cart_contents_total'] = Formatting::format_decimal( $value ); } /** * Set cart tax amount. * * @param float $value Value to set. */ public function set_cart_contents_tax( float $value ) { $this->totals['cart_contents_tax'] = $value; } /** * Set cart total. * * @param float $value Value to set. */ public function set_total( float $value ) { $this->totals['total'] = Formatting::format_decimal( $value, Formatting::get_price_decimals() ); } /** * Set total tax amount. * * @param float $value Value to set. */ public function set_total_tax( float $value ) { // We round here because this is a total entry, as opposed to line items in other setters. $this->totals['total_tax'] = Formatting::round_tax_total( $value ); } /** * Set fee amount. * * @param float $value Value to set. */ public function set_fee_total( float $value ) { $this->totals['fee_total'] = Formatting::format_decimal( $value ); } /** * Set fee tax. * * @param float $value Value to set. */ public function set_fee_tax( float $value ) { $this->totals['fee_tax'] = $value; } /** * Set taxes. * * @param array $value Tax values. */ public function set_shipping_taxes( array $value ) { $this->totals['shipping_taxes'] = $value; } /** * Set taxes. * * @param array $value Tax values. */ public function set_cart_contents_taxes( array $value ) { $this->totals['cart_contents_taxes'] = $value; } /** * Set taxes. * * @param array $value Tax values. */ public function set_fee_taxes( array $value ) { $this->totals['fee_taxes'] = $value; } /** * Return all calculated coupon totals. * * @return array */ public function get_coupon_discount_totals(): array { return $this->coupon_discount_totals; } /** * Return all calculated coupon tax totals. * * @return array */ public function get_coupon_discount_tax_totals(): array { return $this->coupon_discount_tax_totals; } /** * Return the per-item coupon discount breakdown. * * @return array Item Key => Coupon Code => discount amount. */ public function get_coupon_discount_per_item(): array { return $this->coupon_discount_per_item; } /** * Return the rewarded ("free") unit counts. * * @return array Coupon Code => Item Key => units. */ public function get_reward_units(): array { return $this->reward_units; } /** * @deprecated Use get_reward_units(). * * @return array Coupon Code => Item Key => units. */ public function get_bogo_rewards(): array { return $this->get_reward_units(); } /** * Return all calculated totals. * * @return array */ public function get_totals(): array { return empty( $this->totals ) ? $this->default_totals : $this->totals; } /** * Get a total. * * @param string $key Key of element in $totals array. * * @return mixed|float|int */ protected function get_totals_var( string $key ) { return $this->totals[ $key ] ?? $this->default_totals[ $key ]; } /** * Get subtotal. * * @return float|string */ public function get_subtotal() { $subtotal = $this->get_totals_var( 'subtotal' ); /** * Filter cart subtotal. * * @param float|string $subtotal Subtotal. * * @return float|string */ return apply_filters( 'storeengine/cart/get_subtotal', $subtotal ); } /** * Get subtotal_tax. * * @return float */ public function get_subtotal_tax(): float { $subtotal_tax = $this->get_totals_var( 'subtotal_tax' ); /** * Filter cart subtotal tax. * * @param float $subtotal_tax Subtotal tax. * * @return float */ return apply_filters( 'storeengine/cart/get_subtotal_tax', $subtotal_tax ); } /** * Get discount_total. * * @return float */ public function get_discount_total(): float { $discount_total = $this->get_totals_var( 'discount_total' ); /** * Filter cart discount total. * * @param float $discount_total Discount total. * * @return float */ return apply_filters( 'storeengine/cart/get_discount_total', $discount_total ); } /** * Get discount_tax. * * @return float */ public function get_discount_tax(): float { $discount_tax = $this->get_totals_var( 'discount_tax' ); /** * Filter cart discount tax. * * @param float $discount_tax Discount tax. * * @return float */ return apply_filters( 'storeengine/cart/get_discount_tax', $discount_tax ); } /** * Get shipping_total. * * @return float|string */ public function get_shipping_total() { $shipping_total = $this->get_totals_var( 'shipping_total' ); /** * Filter cart shipping total. * * @param float|string $shipping_total Shipping total. * * @return float|string */ return apply_filters( 'storeengine/cart/get_shipping_total', $shipping_total ); } /** * Get shipping_tax. * * @return float */ public function get_shipping_tax(): float { $shipping_tax = $this->get_totals_var( 'shipping_tax' ); /** * Filter cart shipping tax. * * @param float $shipping_tax Shipping tax. * * @return float */ return apply_filters( 'storeengine/cart/get_shipping_tax', $shipping_tax ); } public function get_shipping_tax_total(): float { return $this->get_shipping_tax(); } /** * Gets cart total. This is the total of items in the cart, but after discounts. Subtotal is before discounts. * * @return float|string (can be string due to Formatting::format_decimal()) */ public function get_cart_contents_total() { $cart_contents_total = $this->get_totals_var( 'cart_contents_total' ); /** * Filter cart contents total. * * @param float $cart_contents_total Cart content total. * * @return float */ return apply_filters( 'storeengine/cart/get_cart_contents_total', $cart_contents_total ); } /** * Gets cart tax amount. * * @return float */ public function get_cart_contents_tax(): float { $cart_contents_tax = $this->get_totals_var( 'cart_contents_tax' ); /** * Filter cart contents total tax. * * @param float $cart_contents_tax Contents total tax. * * @return float */ return apply_filters( 'storeengine/cart/get_cart_contents_tax', $cart_contents_tax ); } /** * Gets cart total after calculation. * * @param string $context If the context is view, the value will be formatted for display. This keeps it compatible with pre-3.2 versions. * * @return float|string */ public function get_total( string $context = 'view' ) { /** * Filter get cart total. * * @param float $total Cart total. * * @return float */ $total = apply_filters( 'storeengine/cart/get_total', $this->get_totals_var( 'total' ) ); if ( 'view' === $context ) { /** * Filter cart total. * * @param string $formatted_total Formatted Cart total. * * @return string */ return apply_filters( 'storeengine/cart/total', Formatting::price( $total ) ); } return $total; } /** * Get total tax amount. * * @return float */ public function get_total_tax(): float { $total_tax = $this->get_totals_var( 'total_tax' ); /** * Filter cart total tax. * * @param float $total_tax Cart total tax. * * @return float */ return apply_filters( 'storeengine/cart/get_total_tax', $total_tax ); } /** * Get total fee amount. * * @return float */ public function get_fee_total(): float { $fee_total = $this->get_totals_var( 'fee_total' ); /** * Filter cart fee total. * * @param float $fee_total Cart fee total. * * @return float */ return apply_filters( 'storeengine/cart/get_fee_total', $fee_total ); } /** * Get total fee tax amount. * * @return float */ public function get_fee_tax(): float { $fee_total_tax = $this->get_totals_var( 'fee_tax' ); /** * Filter cart fee total tax. * * @param float $fee_total_tax Cart fee total tax. * * @return float */ return apply_filters( 'storeengine/cart/get_fee_tax', $fee_total_tax ); } /** * Get taxes. */ public function get_shipping_taxes(): array { $shipping_taxes = $this->get_totals_var( 'shipping_taxes' ); /** * Filter shipping taxes. * * @param array $shipping_taxes Cart shipping taxes. * * @return array */ return apply_filters( 'storeengine/cart/get_shipping_taxes', $shipping_taxes ); } /** * Get taxes. */ public function get_cart_contents_taxes(): array { $cart_contents_taxes = $this->get_totals_var( 'cart_contents_taxes' ); /** * Filter cart contents taxes. * * @param array $cart_contents_taxes Cart contents taxes. * * @return array */ return apply_filters( 'storeengine/cart/get_cart_contents_taxes', $cart_contents_taxes ); } /** * Get taxes. */ public function get_fee_taxes(): array { $fee_taxes = $this->get_totals_var( 'fee_taxes' ); /** * Filter cart fee taxes. * * @param array $fee_taxes Cart fee taxes. * * @return array */ return apply_filters( 'storeengine/cart/get_fee_taxes', $fee_taxes ); } /** * Returns the cart and shipping taxes, merged. * * @return array merged taxes */ public function get_taxes(): array { $taxes = Formatting::array_merge_recursive_numeric( $this->get_shipping_taxes(), $this->get_cart_contents_taxes(), $this->get_fee_taxes() ); /** * Filter cart taxes. * * @param array $taxes Cart taxes. * @param Cart $this Cart instance. * * @return array */ return apply_filters( 'storeengine/cart/get_taxes', $taxes, $this ); } /** * Determines the value that the customer spent and the subtotal * displayed, used for things like coupon validation. * * Since the coupon lines are displayed based on the TAX DISPLAY value * of cart, this is used to determine the spend. * * If cart totals are shown including tax, use the subtotal. * If cart totals are shown excluding tax, use the subtotal ex tax * (tax is shown after coupons). * * @return float */ public function get_displayed_subtotal(): float { return $this->display_prices_including_tax() ? $this->get_subtotal() + $this->get_subtotal_tax() : $this->get_subtotal(); } public function get_tax_totals() { $shipping_taxes = $this->get_shipping_taxes(); // Shipping taxes are rounded differently, so we will subtract from all taxes, then round and then add them back. $taxes = $this->get_taxes(); $tax_totals = []; foreach ( $taxes as $key => $tax ) { $code = Tax::get_rate_code( $key ); if ( $code || apply_filters( 'storeengine/cart/remove_taxes_zero_rate_id', 'zero-rated' ) === $key ) { if ( ! isset( $tax_totals[ $code ] ) ) { $tax_totals[ $code ] = new stdClass(); $tax_totals[ $code ]->amount = 0; } $tax_totals[ $code ]->tax_rate_id = $key; $tax_totals[ $code ]->is_compound = Tax::is_compound( $key ); $tax_totals[ $code ]->label = Tax::get_rate_label( $key ); if ( isset( $shipping_taxes[ $key ] ) ) { $tax -= $shipping_taxes[ $key ]; // Round tax total. $tax = Formatting::round_tax_total( $tax ); // Add to total amount. $tax += NumberUtil::round( $shipping_taxes[ $key ], Formatting::get_price_decimals() ); unset( $shipping_taxes[ $key ] ); } $tax_totals[ $code ]->amount += Formatting::round_tax_total( $tax ); // Set formatted amount. $tax_totals[ $code ]->formatted_amount = Formatting::price( $tax_totals[ $code ]->amount ); } } if ( apply_filters( 'storeengine/cart/hide_zero_taxes', true ) ) { $amounts = array_filter( wp_list_pluck( $tax_totals, 'amount' ) ); $tax_totals = array_intersect_key( $tax_totals, $amounts ); } /** * Filter get cart tax totals. * * @param array $tax_totals Cart Tax totals. * @param Cart $this Cart instance. * * @return array */ return apply_filters( 'storeengine/cart/tax_totals', $tax_totals, $this ); } public function get_tax_total(): float { return $this->get_fee_tax() + $this->get_cart_contents_tax(); } /** * Get all tax classes for items in the cart. * * @return array */ public function get_cart_item_tax_classes(): array { $found_tax_classes = []; foreach ( $this->get_cart_items() as $item ) { $product = Helper::get_product( $item->product_id ); if ( $product && ( $product->is_taxable() || $product->is_shipping_taxable() ) ) { $found_tax_classes[] = $product->get_tax_class(); } } return array_unique( $found_tax_classes ); } /** * Get all tax classes for shipping based on the items in the cart. * * @return array */ public function get_cart_item_tax_classes_for_shipping(): array { $found_tax_classes = []; foreach ( $this->get_cart_items() as $item ) { $product = Helper::get_product( $item->product_id ); if ( $product && $product->is_shipping_taxable() ) { $found_tax_classes[] = $product->get_tax_class(); } } return array_unique( $found_tax_classes ); } /** * Gets the cart tax (after calculation). * * @return string formatted price */ public function get_cart_tax(): string { $cart_total_tax = Formatting::round_tax_total( $this->get_cart_contents_tax() + $this->get_shipping_tax() + $this->get_fee_tax() ); $cart_total_tax = $cart_total_tax ? Formatting::price( $cart_total_tax ) : ''; /** * Filter get cart tax. * * @param string $cart_total_tax Cart total tax. * * @return string */ return apply_filters( 'storeengine/cart/get_tax', $cart_total_tax ); } /** * Get a tax amount. * * @param string $tax_rate_id ID of the tax rate to get taxes for. * * @return float amount */ public function get_tax_amount( string $tax_rate_id ) { $taxes = Formatting::array_merge_recursive_numeric( $this->get_cart_contents_taxes(), $this->get_fee_taxes() ); return $taxes[ $tax_rate_id ] ?? 0; } /** * Get a tax amount. * * @param string $tax_rate_id ID of the tax rate to get taxes for. * * @return float amount */ public function get_shipping_tax_amount( string $tax_rate_id ) { $taxes = $this->get_shipping_taxes(); return $taxes[ $tax_rate_id ] ?? 0; } /** * Get tax row amounts with or without compound taxes includes. * * @param bool $compound True if getting compound taxes. * @param bool $display True if getting total to display. * * @return float|string total tax amount, decimal formated if display is true. */ public function get_taxes_total( bool $compound = true, bool $display = true ) { $total = 0; $taxes = $this->get_taxes(); foreach ( $taxes as $key => $tax ) { if ( ! $compound && Tax::is_compound( $key ) ) { continue; } $total += $tax; } if ( $display ) { $total = Formatting::format_decimal( $total, Formatting::get_price_decimals() ); } /** * Filter cart taxes total. * * @param float|int|mixed|string $total * @param bool $compound * @param bool $display * @param Cart $this * * @return mixed */ return apply_filters( 'storeengine/cart/taxes_total', $total, $compound, $display, $this ); } /** * Given a set of packages with rates, get the chosen ones only. * * @param array $calculated_shipping_packages Array of packages. * * @return array */ protected function get_chosen_shipping_methods( array $calculated_shipping_packages = [] ): array { $chosen_methods = []; // Get chosen methods for each package to get our totals. foreach ( $calculated_shipping_packages as $key => $package ) { $chosen_method = $this->get_chosen_shipping_method_for_package( $key, $package ); if ( $chosen_method ) { $chosen_methods[ $key ] = $package['rates'][ $chosen_method ]; } } return $chosen_methods; } /** * Get chosen method for package from session. * * @param int|string $key Key of package. * @param array $package Package data array. * * @return string|bool Either the chosen method ID or false if nothing is chosen yet. */ public function get_chosen_shipping_method_for_package( $key, array $package ) { $chosen_methods = $this->get_meta( 'chosen_shipping_methods' ); $chosen_methods = is_array( $chosen_methods ) ? $chosen_methods : []; $chosen_method = $chosen_methods[ $key ] ?? false; $changed = $this->shipping_methods_have_changed( $key, $package ); if ( ! isset( $package['rates'] ) || ! is_array( $package['rates'] ) ) { $package['rates'] = []; } // If not set, not available, or available methods have changed, set to the DEFAULT option. if ( ! $chosen_method || $changed || ! isset( $package['rates'][ $chosen_method ] ) ) { $chosen_method = $this->get_default_shipping_method_for_package( $key, $package, $chosen_method ); if ( ! empty( $chosen_method ) ) { $chosen_methods[ $key ] = $chosen_method; } $this->set_meta( 'chosen_shipping_methods', $chosen_methods ); /** * Fires when a shipping method is chosen. * * @param string $chosen_method Chosen shipping method. E.g. flat_rate:1. */ do_action( 'storeengine/shipping/method_chosen', $chosen_method ); } return $chosen_method; } /** * See if the methods have changed since the last request. * * @param int|string $key * @param array $package * * @return bool */ public function shipping_methods_have_changed( $key, array $package ): bool { $previous_shipping_methods = $this->get_meta( 'previous_shipping_methods' ); // Get new and old rates. $new_rates = array_keys( $package['rates'] ); $prev_rates = $previous_shipping_methods[ $key ] ?? false; // Update session. $previous_shipping_methods[ $key ] = $new_rates; $this->set_meta( 'previous_shipping_methods', $previous_shipping_methods ); return $new_rates !== $prev_rates; } /** * Choose the default method for a package. * * @param int|string $key Key of package. * @param array $package Package data array. * @param string $chosen_method Chosen shipping method. e.g. flat_rate:1. * * @return string */ public function get_default_shipping_method_for_package( $key, array $package, string $chosen_method ): string { $rate_keys = array_keys( $package['rates'] ); // Default to the first method in the package. This can be sorted in the backend by the merchant. $default = current( $rate_keys ); // @TODO: Check coupons to see if free shipping is available. If it is, we'll use that method as the default. /** * Filters the default shipping method for a package. * * @param string $default Default shipping method. * @param array $rates Shipping rates. * @param string $chosen_method Chosen method id. */ return (string) apply_filters( 'storeengine/shipping/chosen_method', $default, $package['rates'], $chosen_method ); } public function calculate_shipping(): array { // Reset totals. $this->set_shipping_total( 0 ); $this->set_shipping_tax( 0 ); $this->set_shipping_taxes( [] ); $this->shipping_methods = []; $this->has_calculated_shipping = false; if ( ! $this->needs_shipping() || ! $this->show_shipping() ) { return $this->shipping_methods; } $this->has_calculated_shipping = true; $this->shipping_methods = $this->get_chosen_shipping_methods( Shipping::init()->calculate_shipping( $this->get_shipping_packages(), $this ) ); $shipping_costs = wp_list_pluck( $this->shipping_methods, 'cost' ); $shipping_taxes = wp_list_pluck( $this->shipping_methods, 'taxes' ); $merged_taxes = []; foreach ( $shipping_taxes as $taxes ) { foreach ( $taxes as $tax_id => $tax_amount ) { $merged_taxes[ $tax_id ] = ( $merged_taxes[ $tax_id ] ?? 0 ) + $tax_amount; } } $this->set_shipping_total( array_sum( $shipping_costs ) ); $this->set_shipping_tax( array_sum( $merged_taxes ) ); $this->set_shipping_taxes( $merged_taxes ); return $this->shipping_methods; } /** * Will set cart cookies if needed and when possible. * * Headers are only updated if headers have not yet been sent. */ public function maybe_set_cart_cookies() { if ( headers_sent() || ! did_action( 'wp_loaded' ) || isset( $this->logginout ) ) { return; } if ( $this->has_items() ) { $this->set_cart_cookies( true ); } elseif ( isset( $_COOKIE['storeengine_items_in_cart'] ) ) { // WPCS: input var ok. $this->set_cart_cookies( false ); } $this->dedupe_cookies(); } public function remove_cart_cookies() { $this->logginout = true; $this->set_cart_cookies( false ); } /** * Set cart hash cookie and items in cart if not already set. * * @param bool $set Should cookies be set (true) or unset. */ private function set_cart_cookies( $set = true ) { if ( $set ) { if ( ! $this->cart_hash ) { $this->cart_hash = wp_generate_uuid4(); } $setcookies = [ 'storeengine_items_in_cart' => '1', 'storeengine_cart_hash' => $this->cart_hash, ]; foreach ( $setcookies as $name => $value ) { if ( ! isset( $_COOKIE[ $name ] ) || $_COOKIE[ $name ] !== $value ) { Helper::setcookie( $name, $value ); $_COOKIE[ $name ] = $value; } } } else { $unsetcookies = [ 'storeengine_items_in_cart', 'storeengine_cart_hash' ]; foreach ( $unsetcookies as $name ) { if ( isset( $_COOKIE[ $name ] ) ) { Helper::setcookie( $name, 0, time() - HOUR_IN_SECONDS ); unset( $_COOKIE[ $name ] ); } } } do_action( 'storeengine/cart/set_cart_cookies', $set ); } /** * Remove duplicate cookies from the response. */ private function dedupe_cookies() { $all_cookies = array_filter( headers_list(), fn( $header ) => stripos( $header, 'Set-Cookie:' ) !== false ); $final_cookies = []; $update_cookies = false; foreach ( $all_cookies as $cookie ) { list( , $cookie_value ) = explode( ':', $cookie, 2 ); list( $cookie_name, $cookie_value ) = explode( '=', trim( $cookie_value ), 2 ); if ( stripos( $cookie_name, 'storeengine_' ) !== false ) { $key = $this->find_cookie_by_name( $cookie_name, $final_cookies ); if ( false !== $key ) { $update_cookies = true; unset( $final_cookies[ $key ] ); } } $final_cookies[] = $cookie; } if ( $update_cookies ) { header_remove( 'Set-Cookie' ); foreach ( $final_cookies as $cookie ) { // Using header here preserves previous cookie args. header( $cookie, false ); } } } /** * Find a cookie by name in an array of cookies. * * @param string $cookie_name Name of the cookie to find. * @param array $cookies Array of cookies to search. * * @return int|string Key of the cookie if found, false if not. */ private function find_cookie_by_name( string $cookie_name, array $cookies ) { foreach ( $cookies as $key => $cookie ) { if ( strpos( $cookie, $cookie_name ) !== false ) { return $key; } } return false; } public function __clone() { $fees = $this->fees->get_fees(); $this->fees = new CartFees(); $this->fees->set_fees( $fees ); } } // End of file cart.php