PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.1.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.1.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / classes / cart.php

cart.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.1.0, at includes/classes/cart.php

2,748 lines 76.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The Cart
4 * StoreEngine cart.
5 *
6 * @package StoreEngine\Classes
7 * @since 0.0.1
8 * @var 1.5.0
9 */
10
11 namespace StoreEngine\Classes;
12
13 use stdClass;
14 use StoreEngine;
15 use StoreEngine\Classes\Cart\CartTotals;
16 use StoreEngine\Classes\Exceptions\StoreEngineException;
17 use StoreEngine\Models\Cart as CartModel;
18 use StoreEngine\Models\Price as PriceModel;
19 use StoreEngine\Shipping\Shipping;
20 use StoreEngine\Utils\Formatting;
21 use StoreEngine\Utils\Helper;
22 use StoreEngine\Utils\NumberUtil;
23 use StoreEngine\Utils\ShippingUtils;
24 use StoreEngine\Utils\TaxUtil;
25 use WP_Error;
26
27 if ( ! defined( 'ABSPATH' ) ) {
28 exit;
29 }
30
31 /**
32 * @see \WC_Cart
33 */
34 #[\AllowDynamicProperties]
35 class Cart {
36
37 protected int $cart_id = 0;
38
39 protected int $cart_user_id = 0;
40
41 protected string $cart_hash = '';
42
43 /**
44 * @var CartItem[]
45 */
46 public array $cart_items = [];
47
48 protected ?CartFees $fees = null;
49
50 protected array $applied_coupons = [];
51
52 protected array $coupon_discount_totals = [];
53
54 protected array $coupon_discount_tax_totals = [];
55
56 /**
57 * Per-item coupon discount breakdown: Item Key => Coupon Code => discount amount.
58 *
59 * @var array
60 */
61 protected array $coupon_discount_per_item = [];
62
63 /**
64 * BOGO rewarded-unit counts: Coupon Code => Item Key => units (int).
65 *
66 * @var array
67 */
68 protected array $bogo_rewards = [];
69
70 /**
71 * This stores the chosen shipping methods for the cart item packages.
72 *
73 * @var array
74 */
75 protected array $shipping_methods = [];
76
77 /**
78 * Whether the shipping totals have been calculated. This will only return true if shipping was calculated, not if
79 * shipping is disabled or if there are no cart contents.
80 *
81 * @var bool
82 */
83 protected bool $has_calculated_shipping = false;
84
85 /**
86 * Total defaults used to reset.
87 *
88 * @var array
89 */
90 protected array $default_totals = [
91 'subtotal' => 0,
92 'subtotal_tax' => 0,
93 'shipping_total' => 0,
94 'shipping_tax' => 0,
95 'shipping_taxes' => [],
96 'discount_total' => 0,
97 'discount_tax' => 0,
98 'cart_contents_total' => 0,
99 'cart_contents_tax' => 0,
100 'cart_contents_taxes' => [],
101 'fee_total' => 0,
102 'fee_tax' => 0,
103 'fee_taxes' => [],
104 'total' => 0,
105 'total_tax' => 0,
106 ];
107
108 protected array $totals = [];
109
110 protected array $meta = [];
111
112 protected bool $is_dirty = false;
113
114 protected static ?Cart $instance = null;
115
116 private bool $is_session = false;
117
118 private ?Customer $customer = null;
119
120 /**
121 * Create and return instance.
122 *
123 * @return self
124 */
125 public static function init(): Cart {
126 if ( ! self::$instance ) {
127 self::$instance = new self();
128 self::$instance->is_session = true;
129 self::$instance->init_session();
130 }
131
132 return self::$instance;
133 }
134
135 public static function load_by_hash( string $hash, Customer $customer ): Cart {
136 $self = new self();
137 $self->customer = $customer;
138 $self->load_cart( $hash );
139
140 return $self;
141 }
142
143 private function __construct() {
144 $this->fees = new CartFees();
145 }
146
147 private function init_session() {
148 // Cookie events - cart cookies need to be set before headers are sent.
149 add_action( 'storeengine/cart/add_to_cart', [ $this, 'maybe_set_cart_cookies' ] );
150 add_action( 'wp', [ $this, 'maybe_set_cart_cookies' ], 99 );
151 add_action( 'shutdown', [ $this, 'maybe_set_cart_cookies' ], 0 );
152 add_action( 'wp_logout', [ $this, 'remove_cart_cookies' ] );
153
154 $this->cart_hash = Helper::get_cart_hash_from_cookie();
155 $this->cart_user_id = get_current_user_id();
156 $this->customer = StoreEngine::init()->get_customer();
157
158 add_action( 'storeengine/cart/loaded', [ $this, 'calculate_cart_totals' ], 20, 0 );
159 add_action( 'storeengine/cart/add_to_cart', [ $this, 'calculate_cart_totals' ], 20, 0 );
160 add_action( 'storeengine/cart/item_update_quantity', [ $this, 'calculate_cart_totals' ], 20, 0 );
161 add_action( 'storeengine/applied_coupon', [ $this, 'calculate_cart_totals' ], 20, 0 );
162 add_action( 'storeengine/removed_coupon', [ $this, 'calculate_cart_totals' ], 20, 0 );
163 add_action( 'storeengine/cart/item_removed', [ $this, 'calculate_cart_totals' ], 20, 0 );
164 add_action( 'storeengine/cart/item_restored', [ $this, 'calculate_cart_totals' ], 20, 0 );
165 add_action( 'storeengine/update_checkout', [ $this, 'calculate_cart_totals' ], 20, 0 );
166
167 add_action( 'wp_loaded', [ __CLASS__, 'handle_remove_cart_item_request' ], 20 );
168 add_action( 'wp_loaded', [ __CLASS__, 'handle_remove_coupon_request' ], 20 );
169
170 add_action( 'storeengine/cart/check_items', [ $this, 'validate_items' ] );
171 add_action( 'shutdown', [ $this, 'store_on_database' ], 100 );
172
173 // Load cart from db.
174 $this->load_cart( $this->cart_hash, $this->cart_user_id );
175 }
176
177 public function set_cart_hash( string $cart_hash ) {
178 $this->cart_hash = $cart_hash;
179 }
180
181 private function load_cart( ?string $cart_hash = null, int $cart_user_id = 0 ) {
182 if ( ! $cart_hash && ! $cart_user_id ) {
183 return;
184 }
185
186 $carts = CartModel::get_carts_by_hash_or_user_id( $cart_hash, $cart_user_id );
187
188 if ( empty( $carts ) ) {
189 return;
190 }
191
192 do_action_ref_array( 'storeengine/cart/loading_from_session', [ &$this, &$carts ] );
193
194 $cart = reset( $carts );
195
196 if ( ! $this->cart_hash ) {
197 $this->cart_hash = $cart->cart_hash;
198 }
199
200 $this->cart_id = $cart->cart_id;
201 $cart_data = maybe_unserialize( $cart->cart_data );
202
203 if ( ! is_array( $cart_data ) ) {
204 // fallback for old cart.
205 $cart_data = json_decode( $cart_data, true );
206 }
207
208 $has_multiple = count( $carts ) > 1;
209 /** @var CartItem[] $cart_items */
210 $cart_items = [];
211 $cart_fees = [];
212 $coupons = [];
213 $meta = [];
214
215 if ( ! empty( $cart_data ) ) {
216 $cart_items = array_filter( $cart_data['items'] ?? [] );
217 $cart_fees = array_filter( $cart_data['fees'] ?? [] );
218 $coupons = $cart_data['coupons'];
219 $meta = $cart_data['meta'];
220
221 if ( ! empty( $cart_items ) && is_array( reset( $cart_items ) ) ) {
222 // Backward compatibility.
223 $cart_items = array_map( fn( $item ) => new CartItem( $item['key'], $item ), $cart_items );
224 // Remove unsupported price-type (item) if addon not active.
225 $cart_items = array_filter( $cart_items, fn( $item ) => $this->is_price_type_allowed( $item->price_type ) );
226 }
227 }
228
229 if ( $has_multiple ) {
230 $carts = $this->merge_carts( $carts );
231 $coupons = $carts['coupons'];
232 $cart_items = $carts['items'];
233 $cart_fees = $carts['fees'];
234 $meta = $carts['meta'];
235 $this->is_dirty = true;
236 }
237
238 if ( empty( $cart_items ) ) {
239 return;
240 }
241
242 global $wpdb;
243 foreach ( $cart_items as $key => $item ) {
244 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
245 $validate = $wpdb->get_var(
246 $wpdb->prepare(
247 "SELECT * FROM {$wpdb->prefix}storeengine_product_price WHERE id = %d AND product_id = %d;",
248 $item->price_id, $item->product_id
249 )
250 );
251 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
252
253 if ( ! $validate ) {
254 continue;
255 }
256
257 $this->cart_items[ $key ] = $item;
258 }
259
260 if ( $cart_items !== $this->cart_items ) {
261 // @TODO trigger notification for the user that invalid item is removed from cart.
262 $this->is_dirty = true;
263 }
264
265 $coupons = array_unique( $coupons );
266 $this->applied_coupons = array_combine( $coupons, array_map( fn( $coupon ) => new Coupon( $coupon ), $coupons ) );
267 $this->meta = array_filter( $meta );
268 $this->fees->set_fees( $cart_fees );
269
270 if ( $this->is_session ) {
271 do_action_ref_array( 'storeengine/cart/loaded_from_session', [ &$this ] );
272 }
273
274 /**
275 * Fires after cart loaded.
276 */
277 do_action_ref_array( 'storeengine/cart/loaded', [ &$this ] );
278 }
279
280 /**
281 * Merge multiple carts.
282 *
283 * @param array $carts
284 *
285 * @return array {
286 * @var CartItem $items
287 * @var array $fees
288 * @var array $coupons
289 * @var array $meta
290 * }
291 */
292 private function merge_carts( array $carts ): array {
293 $merged_items = [];
294 $cart_coupons = [];
295 $merged_fees = [];
296 $cart_meta = [];
297
298 foreach ( $carts as $cart ) {
299 $cart_data = maybe_unserialize( $cart->cart_data );
300
301 if ( empty( $cart_data['items'] ) ) {
302 // Empty cart data!
303 continue;
304 }
305
306 $cart_items = array_values( array_filter( $cart_data['items'] ) );
307 $cart_coupons = array_merge( $cart_coupons, $cart_data['coupons'] ?? [] );
308 $merged_fees = array_merge( $merged_fees, $cart_data['fees'] ?? [] );
309 $cart_meta = array_merge( $cart_meta, $cart_data['meta'] ?? [] );
310
311 // Check items.
312 foreach ( $cart_items as $cart_item ) {
313 if ( is_array( $cart_item ) ) {
314 // Backward compatibility.
315 $cart_item = new CartItem( $cart_item['key'], $cart_item );
316 }
317
318 $key = $cart_item->key;
319
320 if ( ! $this->is_price_type_allowed( $cart_item->price_type ) ) {
321 // Subscription & other item will be filtered out here if corresponding addon is not active.
322 continue;
323 }
324
325 if ( ! isset( $merged_items[ $key ] ) ) {
326 $has_same_product_id = array_filter( $merged_items, function ( $item ) use ( $cart_item ) {
327 return $item->product_id === $cart_item->product_id &&
328 $item->price_id === $cart_item->price_id &&
329 $item->get_price() === $cart_item->get_price();
330 } );
331
332 if ( empty( $has_same_product_id ) ) {
333 $merged_items[ $key ] = $cart_item;
334 }
335 } else {
336 $merged_items[ $key ]->quantity += $cart_item->quantity;
337 $merged_items[ $key ]->line_subtotal = (float) ( $merged_items[ $key ]->price * $merged_items[ $key ]->quantity );
338 }
339 }
340 }
341
342 $rest_of_carts = array_slice( $carts, 1 );
343 $rest_of_carts_ids = wp_list_pluck( $rest_of_carts, 'cart_id' );
344
345 if ( ! empty( $rest_of_carts_ids ) ) {
346 // @TODO Use wp list pluck -> $rest_of_carts, 'cart_hash';
347 // use cache delete multi with the cart hashes
348 // @TODO use object cache for cart data.
349
350 // Delete other rows.
351 global $wpdb;
352 // Prepare placeholder for cart ids.
353 $placeholders = implode( ',', array_fill( 0, count( $rest_of_carts_ids ), ' %d' ) );
354 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared above.
355 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}storeengine_cart WHERE cart_id IN ($placeholders)", $rest_of_carts_ids ) );
356 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared above.
357
358 do_action( 'storeengine/cart/deleted', $rest_of_carts_ids );
359 }
360
361 return [
362 'items' => array_values( $merged_items ),
363 'fees' => array_unique( $merged_fees ),
364 'coupons' => array_unique( $cart_coupons ),
365 // Meta is an associative key => value map (values may themselves be
366 // arrays, e.g. chosen_shipping_methods). array_unique() casts each value
367 // to a string for comparison — throwing "Array to string conversion" on
368 // array values — and dedupes by value, which would silently drop a meta
369 // key whose value matches another (e.g. has_subscription/has_trial both
370 // true). array_merge() above already merges the map correctly.
371 'meta' => $cart_meta,
372 ];
373 }
374
375 public function validate_items() {
376 $this->validate_cart_items();
377 $this->validate_coupon();
378 }
379
380 private function validate_cart_items(): void {
381 $cart_items = $this->cart_items;
382 $price_ids = wp_list_pluck( $this->cart_items, 'price_id' );
383 /*$priceQuery = new PriceCollection( [
384 'per_page' => -1,
385 'where' => [
386 'key' => 'id',
387 'value' => $price_ids,
388 'compare' => 'in'
389 ]
390 ] );*/
391 $product_prices = PriceModel::get_pricing_with_products( $price_ids );
392 $missing_price_ids = array_diff( $price_ids, array_keys( $product_prices ) );
393
394 // Rebuild items.
395 $new_cart_items = [];
396 foreach ( $this->cart_items as $cart_item ) {
397 if ( in_array( $cart_item->price_id, $missing_price_ids ) || ! isset( $product_prices[ $cart_item->price_id ] ) ) { // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict
398 continue;
399 }
400
401 $product_price = $product_prices[ $cart_item->price_id ];
402 $price = (float) $product_price->price;
403
404 $product_type = get_post_meta( $product_price->product_id, '_storeengine_product_type', true );
405
406 if ( 'variable' === $product_type && ( ! isset( $cart_item->variation_id ) || 0 === $cart_item->variation_id ) ) {
407 continue;
408 }
409
410
411 if ( isset( $cart_item->variation_id ) && $cart_item->variation_id > 0 ) {
412 $variation = Helper::get_product_variation( $cart_item->variation_id );
413 if ( ! $variation ) {
414 continue;
415 }
416
417 $price += (float) $variation->get_price();
418 }
419
420 $new_cart_items[ $cart_item->key ] = $cart_item->set_data( array_merge(
421 [
422 'name' => $product_price->post_title,
423 'product_type' => $product_type,
424 'price_name' => $product_price->price_name,
425 'price' => apply_filters('storeengine/product/get_price', $price, 'view' ),
426 'compare_price' => apply_filters('storeengine/product/get_compare_price', (float) $product_price->compare_price, 'view'),
427 'line_subtotal' => (float) ( $cart_item->quantity * $product_price->price ),
428 ],
429 $product_price->settings
430 ) );
431 }
432
433 $this->cart_items = apply_filters( 'storeengine/cart/validated_cart_items', $new_cart_items, $this );
434 $diff_with_new_cart_items = Helper::array_diff_recursive( $new_cart_items, $cart_items );
435 $this->is_dirty = count( $diff_with_new_cart_items ) > 0 || ( count( $new_cart_items ) !== count( $cart_items ) );
436 }
437
438 private function validate_coupon() {
439 foreach ( $this->applied_coupons as $coupon ) {
440 if ( is_wp_error( $coupon->validate_coupon() ) ) {
441 unset( $this->applied_coupons[ strtolower( $coupon->get_code() ) ] );
442 $this->is_dirty = true;
443 }
444 }
445 }
446
447 public function clear_cart() {
448 // Trigger db-save.
449 $this->is_dirty = true;
450
451 // Reset values.
452 $this->cart_items = [];
453 $this->fees->remove_all_fees();
454 $this->shipping_methods = [];
455 $this->coupon_discount_totals = [];
456 $this->coupon_discount_tax_totals = [];
457 $this->coupon_discount_per_item = [];
458 $this->bogo_rewards = [];
459 $this->applied_coupons = [];
460 $this->totals = $this->default_totals;
461 $this->meta = [];
462 }
463
464 /**
465 * Remove coupons from the cart of a defined type. Type 1 is before tax, type 2 is after tax.
466 */
467 public function remove_coupons() {
468 $this->set_coupon_discount_totals();
469 $this->set_coupon_discount_tax_totals();
470 $this->set_coupon_discount_per_item();
471 $this->set_bogo_rewards();
472 $this->set_applied_coupons();
473 $this->is_dirty = true;
474 }
475
476 /**
477 * Generate a unique ID for the cart item being added.
478 *
479 * @param int $price_id - id of the product the key is being generated for.
480 * @param int $product_id - id of the product the key is being generated for.
481 * @param int|float $amount - id of the product the key is being generated for.
482 * @param int $variation_id of the product the key is being generated for.
483 * @param array $variation data for the cart item.
484 * @param array $item_data other cart item data passed which affects this items uniqueness in the cart.
485 *
486 * @return string cart item key
487 */
488 public function generate_cart_item_id( int $price_id, int $product_id, $amount = 0, int $variation_id = 0, array $variation = [], array $item_data = [] ): string {
489 $id_parts = [ $price_id, $product_id, $amount ];
490
491 if ( $variation_id ) {
492 $id_parts[] = $variation_id;
493 }
494
495 if ( ! empty( $variation ) ) {
496 $variation_key = '';
497 foreach ( $variation as $key => $value ) {
498 $variation_key .= trim( $key ) . trim( $value );
499 }
500
501 $id_parts[] = $variation_key;
502 }
503
504 if ( ! empty( $item_data ) ) {
505 $item_data_key = '';
506 foreach ( $item_data as $key => $value ) {
507 if ( is_array( $value ) || is_object( $value ) ) {
508 $value = http_build_query( $value );
509 }
510
511 $item_data_key .= trim( $key ) . trim( $value );
512 }
513
514 $id_parts[] = $item_data_key;
515 }
516
517 $cart_item_id = md5( implode( '_', $id_parts ) );
518
519 /**
520 * Filter cart item id after generation.
521 *
522 * @param string $cart_item_id Cart item id.
523 * @param int $product_id Product id.
524 * @param int $variation_id Variation id.
525 * @param array $item_data Item data.
526 */
527 return apply_filters( 'storeengine/cart/item_id', $cart_item_id, $product_id, $variation_id, $variation, $item_data );
528 }
529
530 /**
531 * Get allowed price types.
532 * @return array
533 */
534 public function get_allowed_price_types(): array {
535 /**
536 * Allowed Price type filter.
537 *
538 * @param string[] $price_types Allowed price type to be processed.
539 *
540 * @since 1.6.9
541 */
542 return apply_filters( 'storeengine/cart/allowed_price_types', [ 'onetime' ] );
543 }
544
545 public function is_price_type_allowed( string $price_type, $context = 'add-to-cart' ): bool {
546 return in_array( $price_type, $this->get_allowed_price_types(), true );
547 }
548
549 /**
550 * @param int $price_id
551 * @param int $quantity
552 * @param int $variation_id
553 * @param array $variation
554 * @param array $item_data
555 *
556 * @return true|WP_Error
557 */
558 public function add_product_to_cart( int $price_id, int $quantity = 1, int $variation_id = 0, array $variation = [], array $item_data = [] ) {
559 try {
560 $quantity = absint( $quantity );
561 $price = new Price( $price_id );
562 $price_id = $price->get_id();
563 $product = $price->get_product();
564 $product_id = $product ? $product->get_id() : 0;
565
566 if ( ! $quantity ) {
567 return new WP_Error( 'invalid-qty', __( 'Invalid quantity!', 'storeengine' ), [ 'status' => 400 ] );
568 }
569
570 if ( ! $price->get_id() ) {
571 return new WP_Error( 'invalid-price-id', __( 'Invalid price id!', 'storeengine' ), [ 'status' => 400 ] );
572 }
573
574 if ( ! $product ) {
575 return new WP_Error( 'product-not-exists', __( 'This product is no longer available!', 'storeengine' ), [ 'status' => 400 ] );
576 }
577
578 if ( in_array( get_post_status( $product_id ), [ 'trash', 'draft', 'auto-draft' ], true ) ) {
579 return new WP_Error( 'product-in-trash', __( 'This product is no longer available!', 'storeengine' ), [ 'status' => 422 ] );
580 }
581
582 if ( ! $this->is_price_type_allowed( $price->get_price_type() ) ) {
583 return new WP_Error( 'price-type-not-allowed', __( 'Product price not allowed or no longer available.', 'storeengine' ), [
584 'status' => 400,
585 'price_type' => $price->get_price_type()
586 ] );
587 }
588
589 if ( $price->is_subscription() && ! Helper::get_addon_active_status( 'subscription' ) ) {
590 // @Note No longer necessary, deprecated should be removed.
591 // This is no longer necessary for the new allowed-price-types filter.
592 return new WP_Error( 'subscription-addon-not-enabled', __( 'Subscription addon is not enable.', 'storeengine' ), [ 'status' => 400 ] );
593 }
594
595 do_action_ref_array( 'storeengine/cart/before_add_to_cart', [
596 &$this,
597 $price,
598 $quantity,
599 $variation_id,
600 $variation,
601 $item_data
602 ] );
603
604 $amount = $price->get_price();
605
606 if ( 0 < $variation_id ) {
607 $variation_obj = Helper::get_product_variation( $variation_id );
608
609 if ( ! $variation_obj ) {
610 return new WP_Error( 'invalid-product-variation', __( 'Invalid variation selected!', 'storeengine' ) );
611 }
612
613 $amount = $price->get_price() + (float) $variation_obj->get_price();
614 }
615
616 $existing_qty = 0;
617
618 foreach ( $this->cart_items as $existing_item ) {
619 if ( (int) $existing_item->product_id === (int) $product_id && (int) $existing_item->variation_id === (int) $variation_id ) {
620 $existing_qty += (int) $existing_item->quantity;
621 }
622 }
623
624 if ( method_exists( $product, 'is_sold_individually' ) && $product->is_sold_individually() && ( $existing_qty + $quantity ) > 1 ) {
625 return new WP_Error( 'sold-individually', __( 'Only one of this item can be added to the cart.', 'storeengine' ), [ 'status' => 400 ] );
626 }
627
628 $stock_target = null;
629
630 if ( 0 < $variation_id && isset( $variation_obj ) && method_exists( $variation_obj, 'manages_stock' ) && $variation_obj->manages_stock() ) {
631 $stock_target = $variation_obj;
632 } elseif ( method_exists( $product, 'manages_stock' ) ) {
633 $stock_target = $product;
634 }
635
636 if ( $stock_target ) {
637 if ( method_exists( $stock_target, 'is_in_stock' ) && ! $stock_target->is_in_stock() ) {
638 return new WP_Error( 'out-of-stock', __( 'This product is out of stock.', 'storeengine' ), [ 'status' => 400 ] );
639 }
640
641 if ( method_exists( $stock_target, 'has_enough_stock' ) && ! $stock_target->has_enough_stock( $existing_qty + $quantity ) ) {
642 return new WP_Error( 'not-enough-stock', __( 'Not enough stock available for the requested quantity.', 'storeengine' ), [ 'status' => 400 ] );
643 }
644 }
645
646 /**
647 * Filter cart item data during add to cart.
648 *
649 * @param array $item_data Item data.
650 * @param Price $price Price.
651 * @param int $product_id Product id.
652 * @param int $variation_id Variation id.
653 * @param int $quantity Quantity.
654 */
655 $item_data = (array) apply_filters( 'storeengine/cart/add_item_data', $item_data, $price, $product_id, $variation_id, $quantity );
656
657 // Generate a ID based on product ID, variation ID, variation data, and other cart item data.
658 // Use price id to allow multi price per product in cart.
659 // Use amount to allow custom price based same product in cart.
660 $cart_id = $this->generate_cart_item_id(
661 0, //$price->get_id(),
662 $price->get_product_id(),
663 0, //$amount,
664 $variation_id,
665 $variation,
666 $item_data
667 );
668
669 // Find the cart item key in the existing cart.
670 $cart_item_key = $this->find_product_in_cart( $cart_id );
671
672 if ( $cart_item_key ) {
673 $old_quantity = (int) $this->cart_items[ $cart_item_key ]->quantity;
674 $new_quantity = $old_quantity + $quantity;
675 // Remove this condition to allow multi price per product in cart.
676 if ( $this->cart_items[ $cart_item_key ]->price_id === $price->get_id() && $new_quantity ) {
677 // Update qty & price.
678 $this->cart_items[ $cart_item_key ]->quantity = $new_quantity;
679 $this->cart_items[ $cart_item_key ]->line_subtotal = (float) ( $price->get_price() * $new_quantity );
680
681 do_action( 'storeengine/cart/after_item_quantity_update', $this->cart_items[ $cart_item_key ], $old_quantity, $this );
682
683 // Trigger database update.
684 $this->is_dirty = true;
685
686 return true;
687 } else {
688 // Also, remove this to allow multi price per product in cart.
689 $this->remove_cart_item( $cart_item_key );
690 }
691 }
692
693 $cart_item_data = array_merge(
694 [
695 'key' => $cart_id,
696 'product_id' => $price->get_product_id(),
697 'product_type' => $product->get_type(),
698 'taxable' => $price->is_taxable(),
699 'variation_id' => $variation_id,
700 'variation' => $variation,
701 'price_id' => $price->get_id(),
702 'name' => $price->get_post_title(),
703 'price_name' => $price->get_price_name(),
704 'price_type' => $price->get_price_type(),
705 'price' => $amount,
706 'compare_price' => $price->get_compare_price(),
707 'quantity' => $quantity,
708 'line_subtotal' => $amount * $quantity,
709 'item_data' => $item_data,
710 ],
711 $price->get_settings(),
712 );
713
714
715 if ( 'bundled' === $product->get_type() ) {
716 $cart_item_data['bundles'] = $product->get_bundles();
717 }
718
719 $cart_item = new CartItem( $cart_id, $cart_item_data );
720
721 /**
722 * Filter cart item data after add to cart.
723 *
724 * @param CartItem $cart_item_data Cart item data.
725 * @param string $cart_id Cart item id.
726 */
727 $this->cart_items[ $cart_id ] = apply_filters( 'storeengine/cart/add_item', $cart_item, $cart_id );
728
729 if ( ! $price->is_subscription() && $price->get_setup_fee() ) {
730 $feeId = sanitize_title( sprintf( '%s-%s-%s', $product_id, $price_id, $price->get_setup_fee_name() ) );
731 $this->add_fee( $price->get_setup_fee_name(), $price->get_setup_fee_price(), $feeId );
732 }
733
734 // Trigger db update before user tapping into the data.
735 $this->is_dirty = true;
736
737 /**
738 * Fires after adding product on Cart.
739 *
740 * @param string $cart_id Cart id.
741 * @param int $price_id Price id.
742 * @param int $product_id Product id.
743 * @param int $variation_id Variation id.
744 * @param int $quantity Quantity.
745 * @param array $item_data Item data.
746 */
747 do_action( 'storeengine/cart/add_to_cart', $cart_id, $price_id, $product_id, $variation_id, $quantity, $item_data );
748
749 return true;
750 } catch ( StoreEngineException $e ) {
751 Helper::log_error( $e );
752 return $e->get_wp_error();
753 }
754 }
755
756 /**
757 * Validate every line item against current stock. Returns a list of WP_Error
758 * objects (empty when the cart is fine).
759 *
760 * @return WP_Error[]
761 */
762 public function check_cart_items(): array {
763 $errors = [];
764
765 foreach ( $this->cart_items as $item ) {
766 $qty = (int) ( $item->quantity ?? 0 );
767 $product_id = (int) ( $item->product_id ?? 0 );
768 $variation_id = (int) ( $item->variation_id ?? 0 );
769 $target = null;
770
771 if ( $variation_id ) {
772 $variation_obj = Helper::get_product_variation( $variation_id );
773 if ( $variation_obj && method_exists( $variation_obj, 'manages_stock' ) && $variation_obj->manages_stock() ) {
774 $target = $variation_obj;
775 }
776 }
777
778 if ( ! $target && $product_id ) {
779 $product = Helper::get_product( $product_id );
780 if ( $product ) {
781 $target = $product;
782 }
783 }
784
785 if ( ! $target ) {
786 continue;
787 }
788
789 if ( method_exists( $target, 'is_in_stock' ) && ! $target->is_in_stock() ) {
790 $errors[] = new WP_Error( 'out-of-stock', sprintf(
791 /* translators: %s: product name */
792 __( '"%s" is out of stock.', 'storeengine' ),
793 $item->name ?? ''
794 ), [ 'product_id' => $product_id, 'variation_id' => $variation_id ] );
795 continue;
796 }
797
798 if ( method_exists( $target, 'has_enough_stock' ) && ! $target->has_enough_stock( $qty ) ) {
799 $errors[] = new WP_Error( 'not-enough-stock', sprintf(
800 /* translators: %s: product name */
801 __( 'Not enough stock for "%s".', 'storeengine' ),
802 $item->name ?? ''
803 ), [ 'product_id' => $product_id, 'variation_id' => $variation_id ] );
804 }
805 }
806
807 return $errors;
808 }
809
810 public function update_quantity( string $item_key, int $quantity = 1 ) {
811 if ( 0 === $quantity || $quantity < 0 ) {
812 // If we're setting qty to 0 we're removing the item from the cart.
813 if ( $this->remove_cart_item( $item_key ) ) {
814 return true;
815 }
816 }
817
818 if ( ! $this->item_exists( $item_key ) ) {
819 return new WP_Error( 'item-not-found', __( 'Item not found!', 'storeengine' ), [ 'status' => 404 ] );
820 }
821
822 if ( 'subscription' === $this->cart_items[ $item_key ]->price_type ) {
823 return new WP_Error( 'quantity-update-failed-for-subscription', __( 'Cannot update subscription quantity!', 'storeengine' ), [ 'status' => 404 ] );
824 }
825
826 $cart_item = $this->cart_items[ $item_key ];
827 $variation_id = (int) ( $cart_item->variation_id ?? 0 );
828 $product_id = (int) ( $cart_item->product_id ?? 0 );
829 $stock_target = null;
830
831 if ( $variation_id ) {
832 $variation_obj = Helper::get_product_variation( $variation_id );
833 if ( $variation_obj && method_exists( $variation_obj, 'manages_stock' ) && $variation_obj->manages_stock() ) {
834 $stock_target = $variation_obj;
835 }
836 }
837
838 if ( ! $stock_target && $product_id ) {
839 $product = Helper::get_product( $product_id );
840 if ( $product && method_exists( $product, 'manages_stock' ) ) {
841 $stock_target = $product;
842 }
843 }
844
845 if ( $stock_target && method_exists( $stock_target, 'has_enough_stock' ) && ! $stock_target->has_enough_stock( $quantity ) ) {
846 return new WP_Error( 'not-enough-stock', __( 'Not enough stock available for the requested quantity.', 'storeengine' ), [ 'status' => 400 ] );
847 }
848
849 if ( $stock_target && method_exists( $stock_target, 'is_sold_individually' ) && $stock_target->is_sold_individually() && $quantity > 1 ) {
850 return new WP_Error( 'sold-individually', __( 'Only one of this item can be added to the cart.', 'storeengine' ), [ 'status' => 400 ] );
851 }
852
853 do_action( 'storeengine/cart/after_item_quantity_update', $this->cart_items[ $item_key ], $this->cart_items[ $item_key ]->quantity, $this );
854
855 // Update qty.
856 $this->cart_items[ $item_key ]->quantity = $quantity;
857
858 $this->is_dirty = true;
859
860 /**
861 * Fires after updating cart item quantity.
862 *
863 * @param string $item_key Cart item key.
864 * @param int $quantity Cart item quantity.
865 * @param Cart $this Cart instance.
866 */
867 do_action( 'storeengine/cart/item_update_quantity', $item_key, $quantity, $this );
868
869 return true;
870 }
871
872 /**
873 * Returns an array of cart line items.
874 *
875 * @return CartItem[]
876 */
877 public function get_cart_items(): array {
878 return $this->cart_items;
879 }
880
881 public function has_items(): bool {
882 return ! empty( array_filter( $this->cart_items ) );
883 }
884
885 public function item_exists( string $item_key ): bool {
886 return isset( $this->cart_items[ $item_key ] );
887 }
888
889 public function get_cart_item( string $item_key ): ?CartItem {
890 return $this->cart_items[ $item_key ] ?? null;
891 }
892
893 public function remove_cart_item( string $item_key ): bool {
894 if ( isset( $this->cart_items[ $item_key ] ) ) {
895 // Trigger db update.
896 $this->is_dirty = true;
897
898 /**
899 * Fires before removing cart item.
900 *
901 * @param string $item_key Cart item key.
902 * @param Cart $this Cart instance.
903 */
904 do_action_ref_array( 'storeengine/cart/remove_item', [ &$this, $item_key ] );
905
906 $cart_item = $this->cart_items[ $item_key ];
907
908 if ( isset( $cart_item->setup_fee ) && $cart_item->setup_fee ) {
909 $this->fees->remove_fee( sanitize_title( sprintf( '%s-%s-%s', $cart_item->product_id, $cart_item->price_id, $cart_item->setup_fee_name ) ) );
910 }
911
912
913 unset( $this->cart_items[ $item_key ] );
914
915 $this->is_cart_consist_subscription_product();
916
917 /**
918 * Fires after removing cart item.
919 *
920 * @param string $item_key Cart item key.
921 * @param Cart $this Cart instance.
922 */
923 do_action_ref_array( 'storeengine/cart/item_removed', [ &$this, $item_key ] );
924
925 return true;
926 }
927
928 return false;
929 }
930
931 public function get_items_count(): int {
932 return count( $this->cart_items );
933 }
934
935 public function get_count(): int {
936 $count = 0;
937 foreach ( $this->cart_items as $cart_item ) {
938 $count += $cart_item->quantity;
939 }
940
941 return $count;
942 }
943
944 public function get_cart_item_by_product( int $product_id, int $price_id = null ): ?CartItem {
945 foreach ( $this->cart_items as $cart_item ) {
946 if ( $product_id && $price_id ) {
947 if ( (int) $cart_item->product_id === $product_id && (int) $cart_item->price_id === $price_id ) {
948 return $cart_item;
949 }
950
951 continue;
952 }
953
954 if ( (int) $cart_item->product_id === $product_id ) {
955 return $cart_item;
956 }
957 }
958
959 return null;
960 }
961
962 /**
963 * @param int $product_id
964 *
965 * @return CartItem[]
966 */
967 public function get_cart_items_by_product( int $product_id ): array {
968 return array_filter( $this->cart_items, function ( $cart_item ) use ( $product_id ) {
969 return $cart_item->product_id === $product_id;
970 } );
971 }
972
973 /**
974 * Trigger an action so 3rd parties can add custom fees.
975 */
976 public function calculate_fees() {
977 do_action( 'storeengine/cart/calculate_fees', $this );
978 }
979
980 /**
981 * Return reference to fees API.
982 *
983 * @return CartFees
984 */
985 public function fees_api(): ?CartFees {
986 return $this->fees;
987 }
988
989 /**
990 * Add additional fee to the cart.
991 *
992 * This method should be called on a callback attached to the
993 * woocommerce_cart_calculate_fees action during cart/checkout. Fees do not
994 * persist.
995 *
996 * @param string $name Unique name for the fee. Multiple fees of the same name cannot be added.
997 * @param string|int|float $amount Fee amount (do not enter negative amounts).
998 * @param string $id Unique id for the fee. Multiple fees of the id name cannot be added.
999 * @param bool $taxable Is the fee taxable? (default: false).
1000 * @param string $tax_class The tax class for the fee if taxable. A blank string is standard tax class. (default: '').
1001 *
1002 * @uses WC_Cart_Fees::add_fee
1003 */
1004 public function add_fee( string $name, $amount, string $id = '', bool $taxable = true, string $tax_class = '' ) {
1005 $this->fees_api()->add_fee( [
1006 'id' => $id,
1007 'name' => $name,
1008 'amount' => (float) $amount,
1009 'taxable' => $taxable,
1010 'tax_class' => $tax_class,
1011 ] );
1012 }
1013
1014 /**
1015 * Return all added fees from the Fees API.
1016 *
1017 * @return array
1018 * @uses CartFees::get_fees
1019 */
1020 public function get_fees(): array {
1021 return $this->fees_api()->get_fees();
1022 }
1023
1024
1025 /**
1026 * Gets the sub-total (after calculation).
1027 *
1028 * @param bool $compound whether to include compound taxes.
1029 *
1030 * @return string formatted price
1031 */
1032 public function get_cart_subtotal( bool $compound = false ): string {
1033 /**
1034 * If the cart has compound tax, we want to show the subtotal as cart + shipping + non-compound taxes (after discount).
1035 */
1036 if ( $compound ) {
1037 $cart_subtotal = Formatting::price( $this->get_cart_contents_total() + $this->get_shipping_total() + $this->get_taxes_total( false, false ) );
1038 } elseif ( $this->display_prices_including_tax() ) {
1039 $cart_subtotal = Formatting::price( $this->get_subtotal() + $this->get_subtotal_tax() );
1040
1041 if ( $this->get_subtotal_tax() > 0 && ! TaxUtil::prices_include_tax() ) {
1042 $cart_subtotal .= ' <small class="tax_label">' . Countries::init()->inc_tax_or_vat() . '</small>';
1043 }
1044 } else {
1045 $cart_subtotal = Formatting::price( $this->get_subtotal() );
1046 if ( $this->get_subtotal_tax() > 0 && TaxUtil::prices_include_tax() ) {
1047 $cart_subtotal .= ' <small class="tax_label">' . Countries::init()->ex_tax_or_vat() . '</small>';
1048 }
1049 }
1050
1051 /**
1052 * Filter cart subtotal.
1053 *
1054 * @param string $cart_subtotal Cart subtotal.
1055 * @param int $compound Compound.
1056 * @param Cart $this Cart instance.
1057 */
1058 return apply_filters( 'storeengine/cart/subtotal', $cart_subtotal, $compound, $this );
1059 }
1060
1061 /**
1062 * Get the product row price per item.
1063 *
1064 * @param float $price Price.
1065 * @param ?int $priceId Price ID.
1066 * @param ?int $product Product ID.
1067 *
1068 * @return string formatted price
1069 * @throws StoreEngineException
1070 */
1071 public function get_product_price( float $price, ?int $priceId = null, ?int $product = null ): string {
1072 if ( $this->display_prices_including_tax() ) {
1073 $product_price = Formatting::get_price_including_tax( $price, $priceId, $product );
1074 } else {
1075 $product_price = Formatting::get_price_excluding_tax( $price, $priceId, $product );
1076 }
1077
1078 $formatted_price = Formatting::price( $product_price );
1079
1080 /**
1081 * Filter the formatted product price from price(float).
1082 *
1083 * @param string $formatted_price Formatted price.
1084 * @param Price $price Price.
1085 * @param ?int $priceId Price ID.
1086 * @param ?int $product Product ID.
1087 */
1088 return apply_filters( 'storeengine/cart/product_price', $formatted_price, Helper::get_price( $priceId ), $priceId, $product );
1089 }
1090
1091 /**
1092 * Get the product row subtotal.
1093 *
1094 * Gets the tax etc. to avoid rounding issues.
1095 *
1096 * When on the checkout (review order), this will get the subtotal based on the customer's tax rate rather than the base rate.
1097 *
1098 * @param float $price Product object.
1099 * @param int $price_id
1100 * @param int $product Product object.
1101 * @param int $quantity Quantity being purchased.
1102 * @param bool $taxable
1103 *
1104 * @return string formatted price
1105 * @throws StoreEngineException
1106 */
1107 public function get_product_subtotal( float $price, int $price_id, int $product, int $quantity, bool $taxable = true ): string {
1108 if ( TaxUtil::is_tax_enabled() && $taxable ) {
1109 if ( $this->display_prices_including_tax() ) {
1110 $product_subtotal = Formatting::price(
1111 Formatting::get_price_including_tax(
1112 $price,
1113 null,
1114 $product,
1115 [
1116 'qty' => $quantity,
1117 'taxable' => $taxable,
1118 ]
1119 )
1120 );
1121
1122 if ( ! TaxUtil::prices_include_tax() && $this->get_subtotal_tax() > 0 ) {
1123 $product_subtotal .= ' <small class="tax_label">' . Countries::init()->inc_tax_or_vat() . '</small>';
1124 }
1125 } else {
1126 $product_subtotal = Formatting::price(
1127 Formatting::get_price_excluding_tax(
1128 $price,
1129 null,
1130 $product,
1131 [
1132 'qty' => $quantity,
1133 'taxable' => $taxable,
1134 ]
1135 )
1136 );
1137
1138 if ( TaxUtil::prices_include_tax() && $this->get_subtotal_tax() > 0 ) {
1139 $product_subtotal .= ' <small class="tax_label">' . Countries::init()->ex_tax_or_vat() . '</small>';
1140 }
1141 }
1142 } else {
1143 $row_price = $price * $quantity;
1144 $product_subtotal = Formatting::price( $row_price );
1145 }
1146
1147 /**
1148 * Filter formatted product subtotal from price(float).
1149 *
1150 * @param String $product_subtotal Formatted product subtotal.
1151 * @param int $product Product ID.
1152 * @param int $quantity Quantity.
1153 * @param Cart $this Cart instance.
1154 */
1155 return (string) apply_filters( 'storeengine/cart/product_subtotal', $product_subtotal, Helper::get_price( $price_id ), $quantity, $this );
1156 }
1157
1158 public function get_total_discount() {
1159 $total = 0;
1160 foreach ( $this->get_coupons() as $coupon ) {
1161 $total += $this->get_coupon_discount_amount( $coupon->get_code(), $this->display_prices_including_tax() );
1162 }
1163
1164 return $total;
1165 }
1166
1167 /**
1168 * Get the discount amount for a used coupon.
1169 *
1170 * @param string $code coupon code.
1171 * @param bool $ex_tax inc or ex tax.
1172 *
1173 * @return float discount amount
1174 */
1175 public function get_coupon_discount_amount( string $code, bool $ex_tax = true ): float {
1176 // Discount totals are keyed by the lowercased coupon code (see Discounts and
1177 // apply_coupon, which both use strtolower()). Normalize the lookup so a coupon
1178 // whose code contains uppercase letters still resolves its discount instead of
1179 // rendering -$0.00 in the totals.
1180 $code = strtolower( $code );
1181 $discount_amount = $this->coupon_discount_totals[ $code ] ?? 0;
1182
1183 if ( ! $ex_tax ) {
1184 $discount_amount += $this->get_coupon_discount_tax_amount( $code );
1185 }
1186
1187 return Formatting::round_discount( $discount_amount, Formatting::get_price_decimals() );
1188 }
1189
1190 /**
1191 * Get the discount tax amount for a used coupon (for tax inclusive prices).
1192 *
1193 * @param string $code coupon code.
1194 *
1195 * @return float discount amount
1196 */
1197 public function get_coupon_discount_tax_amount( string $code ): float {
1198 // Keyed by lowercased coupon code — normalize to match (see get_coupon_discount_amount).
1199 $code = strtolower( $code );
1200
1201 return Formatting::round_discount( $this->coupon_discount_tax_totals[ $code ] ?? 0, Formatting::get_price_decimals() );
1202 }
1203
1204 public function apply_coupon( $coupon_code ) {
1205 // Check if already applied to the cart.
1206 if ( $this->is_coupon_applied( $coupon_code ) ) {
1207 return new WP_Error(
1208 'coupon_already_applied',
1209 sprintf(
1210 // translators: %s: Coupon code.
1211 esc_html__( 'Coupon code "%s" has already been applied.', 'storeengine' ),
1212 esc_html( $coupon_code )
1213 )
1214 );
1215 }
1216
1217 // Load & validate coupon.
1218 $coupon = new Coupon( $coupon_code );
1219 $is_valid = $coupon->validate_coupon();
1220
1221 if ( is_wp_error( $is_valid ) ) {
1222 return $is_valid;
1223 }
1224
1225 // Store.
1226 $this->applied_coupons[ strtolower( $coupon->code ) ] = $coupon;
1227
1228 // Trigger save.
1229 $this->is_dirty = true;
1230
1231 /**
1232 * Fires after applied coupon on cart.
1233 */
1234 do_action( 'storeengine/applied_coupon' );
1235
1236 return true;
1237 }
1238
1239 public function remove_coupon( $coupon_code ) {
1240 $coupon_code = strtolower( $coupon_code );
1241 if ( isset( $this->applied_coupons[ $coupon_code ] ) ) {
1242 unset( $this->applied_coupons[ $coupon_code ] );
1243 $this->is_dirty = true;
1244
1245 /**
1246 * Fires after removing coupon from cart.
1247 */
1248 do_action( 'storeengine/removed_coupon' );
1249
1250 return true;
1251 }
1252
1253 return new WP_Error( 'not_found_coupon', __( 'Coupon not found!', 'storeengine' ) );
1254 }
1255
1256 /**
1257 * @return Coupon[]
1258 */
1259 public function get_coupons(): array {
1260 return $this->applied_coupons;
1261 }
1262
1263 public function is_coupon_applied( $coupon_code ): bool {
1264 return isset( $this->applied_coupons[ strtolower( $coupon_code ) ] );
1265 }
1266
1267 public function calculate_cart_totals() {
1268 $this->reset_totals();
1269
1270 if ( ! count( $this->cart_items ) && ! count( $this->get_fees() ) ) {
1271 return;
1272 }
1273
1274 /**
1275 * Fires before calculating cart totals.
1276 *
1277 * @param Cart $this Cart instance.
1278 */
1279 do_action( 'storeengine/cart/before_calculate_totals', $this );
1280
1281 new CartTotals( $this );
1282
1283 /**
1284 * Fires after calculating cart totals.
1285 *
1286 * @param Cart $this Cart instance.
1287 */
1288 do_action( 'storeengine/cart/after_calculate_totals', $this );
1289 }
1290
1291 public function calculate_totals() {
1292 $this->calculate_cart_totals();
1293 }
1294
1295 /**
1296 * Looks at the totals to see if payment is actually required.
1297 *
1298 * @return bool
1299 */
1300 public function needs_payment(): bool {
1301 $needs_payment = 0 < $this->get_total( 'edit' );
1302
1303 /**
1304 * Filter cart needs payment.
1305 *
1306 * @param bool $needs_payment Payment needed or not.
1307 * @param Cart $this Cart instance.
1308 */
1309 return apply_filters( 'storeengine/cart/needs_payment', $needs_payment, $this );
1310 }
1311
1312 /**
1313 * Get selected shipping methods after calculation.
1314 *
1315 * @return StoreEngine\Shipping\ShippingRate[]
1316 */
1317 public function get_shipping_methods(): array {
1318 return $this->shipping_methods;
1319 }
1320
1321 /**
1322 * Whether the shipping totals have been calculated.
1323 *
1324 * @return bool
1325 */
1326 public function has_calculated_shipping(): bool {
1327 return $this->has_calculated_shipping;
1328 }
1329
1330 /**
1331 * Looks through the cart to see if shipping is actually required.
1332 *
1333 * @return bool whether the cart needs shipping
1334 */
1335 public function needs_shipping(): bool {
1336 if ( ! ShippingUtils::is_shipping_enabled() || 0 === ShippingUtils::get_shipping_methods_count() ) {
1337 return false;
1338 }
1339
1340 $needs_shipping = false;
1341
1342 foreach ( $this->get_cart_items() as $cart_item ) {
1343 $product = Helper::get_product( $cart_item->product_id );
1344 if ( $product && $product->needs_shipping() ) {
1345 $needs_shipping = true;
1346 break;
1347 }
1348 }
1349
1350 return apply_filters( 'storeengine/cart/needs_shipping', $needs_shipping );
1351 }
1352
1353 /**
1354 * Sees if the customer has entered enough data to calculate shipping.
1355 *
1356 * @return bool
1357 */
1358 public function show_shipping(): bool {
1359 // If there are no shipping methods or no cart contents, no need to calculate shipping.
1360 if ( ! ShippingUtils::is_shipping_enabled() || 0 === ShippingUtils::get_shipping_methods_count( true ) || ! $this->get_count() ) {
1361 return false;
1362 }
1363
1364 if ( Helper::get_settings( 'storeengine/shipping/cost_requires_address', true ) ) {
1365 $customer = $this->get_customer();
1366
1367 if ( ! $customer instanceof Customer || ! $customer->has_full_shipping_address() ) {
1368 return false;
1369 }
1370 }
1371
1372 /**
1373 * Filter to allow plugins to prevent shipping calculations.
1374 *
1375 * @param bool $ready Whether the cart is ready to calculate shipping.
1376 */
1377 return apply_filters( 'storeengine/cart/ready_to_calc_shipping', true );
1378 }
1379
1380 public function is_product_in_cart( $product_id ) {
1381 foreach ( $this->cart_items as $item_key => $cart_item ) {
1382 if ( $cart_item->product_id === $product_id ) {
1383 return $item_key;
1384 }
1385 }
1386
1387 return '';
1388 }
1389
1390 public function is_price_in_cart( $price_id ) {
1391 foreach ( $this->cart_items as $item_key => $cart_item ) {
1392 if ( (int) $cart_item->price_id === $price_id ) {
1393 return $item_key;
1394 }
1395 }
1396
1397 return '';
1398 }
1399
1400 public function has_onetime_products(): bool {
1401 $has = false;
1402 foreach ( $this->cart_items as $cart_item ) {
1403 if ( 'onetime' === $cart_item->price_type ) {
1404 $has = true;
1405 break;
1406 }
1407 }
1408
1409 return $has;
1410 }
1411
1412 /**
1413 * Check if product is in the cart and return cart item key.
1414 *
1415 * Cart item key will be unique based on the item and its properties, such as variations.
1416 *
1417 * @param string|bool $cart_id id of product to find in the cart.
1418 *
1419 * @return string cart item key
1420 */
1421 public function find_product_in_cart( $cart_id = false ): string {
1422 if ( false !== $cart_id ) {
1423 if ( isset( $this->cart_items[ $cart_id ] ) ) {
1424 return $cart_id;
1425 }
1426 }
1427
1428 return '';
1429 }
1430
1431 public function is_cart_consist_subscription_product(): bool {
1432 $has_subscription = false;
1433 $has_trial = false;
1434 $active_addon = Helper::get_addon_active_status( 'subscription' );
1435
1436 foreach ( $this->cart_items as $cart_item ) {
1437 if ( 'subscription' === $cart_item->price_type && $active_addon ) {
1438 $has_subscription = true;
1439 $has_trial = $cart_item->trial && $cart_item->trial_days > 0;
1440 break;
1441 }
1442 }
1443
1444 $old_has_sub = $this->meta['has_subscription'] ?? false;
1445 $old_has_trial = $this->meta['has_trial'] ?? false;
1446
1447 $this->meta['has_subscription'] = $has_subscription;
1448 $this->meta['has_trial'] = $has_trial;
1449
1450 if ( ( ! $old_has_sub && $has_subscription ) || ( ! $old_has_trial && $has_trial ) ) {
1451 $this->is_dirty = true;
1452 }
1453
1454 return $has_subscription;
1455 }
1456
1457 /**
1458 * Set multiple meta data together.
1459 *
1460 * @param array<string,mixed> $metadata Meta data.
1461 *
1462 * @return void
1463 */
1464 public function set_meta_multiple( array $metadata ) {
1465 foreach ( $metadata as $meta_key => $value ) {
1466 $this->set_meta( $meta_key, $value );
1467 }
1468 }
1469
1470 /**
1471 * Add/set cart meta data.
1472 *
1473 * @param string $key Meta key.
1474 * @param mixed $value Value to store, any serializable value.
1475 *
1476 * @return void
1477 */
1478 public function set_meta( string $key, $value ) {
1479 if ( array_key_exists( $key, $this->meta ) && $value === $this->meta[ $key ] ) {
1480 return;
1481 }
1482
1483 $this->meta[ $key ] = $value;
1484 $this->is_dirty = true;
1485 }
1486
1487 /**
1488 * Remove cart metadata.
1489 *
1490 * @param string|string[] $keys Meta key.
1491 *
1492 * @return void
1493 */
1494 public function remove_meta( $keys ) {
1495 if ( is_string( $keys ) ) {
1496 $keys = array_map( 'trim', explode( ',', $keys ) );
1497 }
1498
1499 foreach ( $keys as $key ) {
1500 if ( array_key_exists( $key, $this->meta ) ) {
1501 unset( $this->meta[ $key ] );
1502 $this->is_dirty = true;
1503 }
1504 }
1505 }
1506
1507 /**
1508 * Get cart metadata.
1509 *
1510 * @param string $key Meta key.
1511 *
1512 * @return mixed|null meta value if exists or null.
1513 */
1514 public function get_meta( string $key ) {
1515 return $this->meta[ $key ] ?? null;
1516 }
1517
1518 public function get_meta_data(): array {
1519 return $this->meta;
1520 }
1521
1522 /**
1523 * Checks if cart has subscription.
1524 *
1525 * @return bool
1526 * @deprecated
1527 */
1528 public function has_subscription_product(): bool {
1529 return Helper::get_addon_active_status( 'subscription' ) && $this->get_meta( 'has_subscription' );
1530 }
1531
1532 public function is_cart_empty(): bool {
1533 return 0 === count( $this->cart_items );
1534 }
1535
1536 public function store_on_database() {
1537 if ( self::$instance !== $this || ! $this->is_session || ! $this->is_dirty ) {
1538 return;
1539 }
1540
1541 try {
1542 $this->calculate_cart_totals();
1543 } catch ( \Throwable $e ) {
1544 Helper::log_error( $e );
1545 }
1546
1547 $data = [
1548 'items' => $this->cart_items,
1549 'fees' => $this->get_fees(),
1550 'coupons' => ! empty( $this->cart_items ) ? array_keys( $this->applied_coupons ) : [],
1551 'totals' => $this->totals,
1552 'meta' => $this->meta,
1553 ];
1554
1555 if ( $this->cart_id ) {
1556 CartModel::update( $this->cart_id, $this->cart_hash, $data );
1557 } else {
1558 $this->cart_id = CartModel::create( $this->cart_user_id, $this->cart_hash, $data );
1559 }
1560
1561 do_action_ref_array( 'storeengine/cart/saved', [ &$this ] );
1562
1563 $this->is_dirty = false;
1564 }
1565
1566 public function get_cart_hash(): string {
1567 return $this->cart_hash;
1568 }
1569
1570 public function get_cart_id(): string {
1571 return $this->cart_id;
1572 }
1573
1574 /**
1575 * Get packages to calculate shipping for.
1576 *
1577 * This lets us calculate costs for carts that are shipped to multiple locations.
1578 *
1579 * Shipping methods are responsible for looping through these packages.
1580 *
1581 * By default, we pass the cart itself as a package - plugins can change this.
1582 * through the filter and break it up.
1583 *
1584 * @return array of cart items
1585 */
1586 public function get_shipping_packages(): array {
1587 return apply_filters(
1588 'storeengine/cart/shipping_packages',
1589 [
1590 [
1591 'contents' => $this->get_items_needing_shipping(),
1592 'contents_cost' => array_sum( wp_list_pluck( $this->get_items_needing_shipping(), 'line_total' ) ),
1593 'applied_coupons' => $this->get_coupons(),
1594 'user' => [
1595 'ID' => get_current_user_id(),
1596 ],
1597 'destination' => [
1598 'country' => $this->get_customer()->get_shipping_country(),
1599 'state' => $this->get_customer()->get_shipping_state(),
1600 'postcode' => $this->get_customer()->get_shipping_postcode(),
1601 'city' => $this->get_customer()->get_shipping_city(),
1602 'address_1' => $this->get_customer()->get_shipping_address_1(),
1603 'address_2' => $this->get_customer()->get_shipping_address_2(),
1604 ],
1605 'cart_subtotal' => $this->get_displayed_subtotal(),
1606 ],
1607 ],
1608 $this
1609 );
1610 }
1611
1612 /**
1613 * Get only items that need shipping.
1614 *
1615 * @return CartItem[]
1616 */
1617 protected function get_items_needing_shipping(): array {
1618 return array_filter( $this->get_cart_items(), [ $this, 'filter_items_needing_shipping' ] );
1619 }
1620
1621 /**
1622 * Filter items needing shipping callback.
1623 *
1624 * @param CartItem $item Item to check for shipping.
1625 *
1626 * @return bool
1627 */
1628 protected function filter_items_needing_shipping( CartItem $item ): bool {
1629 $product = Helper::get_product( $item->product_id );
1630
1631 return $product && $product->needs_shipping();
1632 }
1633
1634 /**
1635 * Returns 'incl' if tax should be included in cart, otherwise returns 'excl'.
1636 *
1637 * @return string
1638 */
1639 public function get_tax_price_display_mode(): string {
1640 return TaxUtil::get_tax_price_display_mode( $this->get_customer() );
1641 }
1642
1643 /**
1644 * Return whether-or-not the cart is displaying prices including tax, rather than excluding tax.
1645 *
1646 * @return bool
1647 */
1648 public function display_prices_including_tax(): bool {
1649 /**
1650 * Filtering if display prices including tax or not!
1651 *
1652 * @param bool $prices_including_tax True / False.
1653 */
1654 return apply_filters_deprecated(
1655 'storeengine/cart/display_prices_including_tax',
1656 [ TaxUtil::display_prices_including_tax( $this->get_customer() ) ],
1657 '1.6.4',
1658 'storeengine/tax/display_prices_including_tax'
1659 );
1660 }
1661
1662 /**
1663 * Return whether-or-not the cart is displaying prices including tax, rather than excluding tax.
1664 *
1665 * @return bool
1666 */
1667 public function display_prices_excluding_tax(): bool {
1668 return TaxUtil::display_prices_excluding_tax( $this->get_customer() );
1669 }
1670
1671 /**
1672 * Get cart's owner.
1673 *
1674 * @return Customer|null
1675 */
1676 public function get_customer(): ?Customer {
1677 return $this->customer;
1678 }
1679
1680 /**
1681 * Reset cart totals to the defaults. Useful before running calculations.
1682 */
1683 private function reset_totals() {
1684 $this->totals = $this->default_totals;
1685
1686 /**
1687 * Fires after reset the cart.
1688 *
1689 * @param Cart $this Cart instance.
1690 */
1691 do_action( 'storeengine/cart/reset', $this );
1692 }
1693
1694 /**
1695 * @param string $item_key
1696 *
1697 * @return string
1698 */
1699 public static function get_remove_item_url( string $item_key ): string {
1700 $cart_page_url = Helper::get_page_permalink( 'cart_page' );
1701 $remove_item_url = $cart_page_url ? wp_nonce_url( add_query_arg( 'remove_item', $item_key, $cart_page_url ), 'storeengine/cart' ) : '';
1702
1703
1704 /**
1705 * Filter cart item remove url.
1706 *
1707 * @param string $remove_item_url The remove of a cart item.
1708 *
1709 * @returns string Remove item url
1710 */
1711 return apply_filters( 'storeengine/cart/get_remove_url', $remove_item_url );
1712 }
1713
1714 public static function handle_remove_cart_item_request(): void {
1715 if ( isset( $_GET['remove_item'], $_REQUEST['_wpnonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ), 'storeengine/cart' ) ) {
1716 StoreEngine\Utils\Caching::nocache_headers();
1717
1718 $item_key = sanitize_text_field( wp_unslash( $_GET['remove_item'] ) );
1719 $item = self::init()->get_cart_item( $item_key );
1720
1721 if ( $item ) {
1722 self::init()->remove_cart_item( $item_key );
1723 }
1724
1725 if ( wp_get_referer() ) {
1726 $remove = [
1727 'remove_item',
1728 'add-to-cart',
1729 'added-to-cart',
1730 'order_again',
1731 '_wpnonce',
1732 ];
1733 wp_safe_redirect( remove_query_arg( $remove, add_query_arg( 'removed_item', '1', wp_get_referer() ) ) );
1734 exit;
1735 }
1736 }
1737 }
1738
1739 public static function handle_remove_coupon_request(): void {
1740 if ( isset( $_GET['remove_coupon'], $_GET['_wpnonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'storeengine/cart/remove_coupon' ) ) {
1741 StoreEngine\Utils\Caching::nocache_headers();
1742
1743 $coupon_code = sanitize_text_field( urldecode( wp_unslash( $_GET['remove_coupon'] ) ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1744
1745 if ( $coupon_code ) {
1746 self::init()->remove_coupon( $coupon_code );
1747 }
1748
1749 if ( wp_get_referer() ) {
1750 wp_safe_redirect( remove_query_arg( [ 'remove_coupon', '_wpnonce' ], wp_get_referer() ) );
1751 exit;
1752 }
1753 }
1754 }
1755
1756 // Totals
1757
1758 /**
1759 * Sets the array of calculated coupon totals.
1760 *
1761 * @param array $value Value to set.
1762 */
1763 public function set_coupon_discount_totals( array $value = [] ) {
1764 $this->coupon_discount_totals = $value;
1765 }
1766
1767 /**
1768 * Sets the array of calculated coupon tax totals.
1769 *
1770 * @param array $value Value to set.
1771 */
1772 public function set_coupon_discount_tax_totals( array $value = [] ) {
1773 $this->coupon_discount_tax_totals = $value;
1774 }
1775
1776 /**
1777 * Sets the per-item coupon discount breakdown.
1778 *
1779 * @param array $value Item Key => Coupon Code => discount amount.
1780 */
1781 public function set_coupon_discount_per_item( array $value = [] ) {
1782 $this->coupon_discount_per_item = $value;
1783 }
1784
1785 /**
1786 * Sets the BOGO rewarded-unit counts.
1787 *
1788 * @param array $value Coupon Code => Item Key => units.
1789 */
1790 public function set_bogo_rewards( array $value = [] ) {
1791 $this->bogo_rewards = $value;
1792 }
1793
1794 /**
1795 * Sets the array of applied coupon codes.
1796 *
1797 * @param array $value List of applied coupon codes.
1798 */
1799 public function set_applied_coupons( array $value = [] ) {
1800 $this->applied_coupons = $value;
1801 }
1802
1803 /**
1804 * Set all calculated totals.
1805 *
1806 * @param array $value Value to set.
1807 */
1808 public function set_totals( array $value = [] ) {
1809 $this->totals = wp_parse_args( $value, $this->default_totals );
1810 }
1811
1812 /**
1813 * Set subtotal.
1814 *
1815 * @param float|string $value Value to set.
1816 */
1817 public function set_subtotal( $value ) {
1818 $this->totals['subtotal'] = Formatting::format_decimal( $value );
1819 }
1820
1821 /**
1822 * Set subtotal.
1823 *
1824 * @param float $value Value to set.
1825 */
1826 public function set_subtotal_tax( float $value ) {
1827 $this->totals['subtotal_tax'] = $value;
1828 }
1829
1830 /**
1831 * Set discount_total.
1832 *
1833 * @param float $value Value to set.
1834 */
1835 public function set_discount_total( float $value ) {
1836 $this->totals['discount_total'] = $value;
1837 }
1838
1839 /**
1840 * Set discount_tax.
1841 *
1842 * @param float $value Value to set.
1843 */
1844 public function set_discount_tax( float $value ) {
1845 $this->totals['discount_tax'] = $value;
1846 }
1847
1848 /**
1849 * Set shipping_total.
1850 *
1851 * @param float $value Value to set.
1852 */
1853 public function set_shipping_total( float $value ) {
1854 $this->totals['shipping_total'] = Formatting::format_decimal( $value );
1855 }
1856
1857 /**
1858 * Set shipping_tax.
1859 *
1860 * @param float $value Value to set.
1861 */
1862 public function set_shipping_tax( float $value ) {
1863 $this->totals['shipping_tax'] = $value;
1864 }
1865
1866 /**
1867 * Set cart_contents_total.
1868 *
1869 * @param float $value Value to set.
1870 */
1871 public function set_cart_contents_total( float $value ) {
1872 $this->totals['cart_contents_total'] = Formatting::format_decimal( $value );
1873 }
1874
1875 /**
1876 * Set cart tax amount.
1877 *
1878 * @param float $value Value to set.
1879 */
1880 public function set_cart_contents_tax( float $value ) {
1881 $this->totals['cart_contents_tax'] = $value;
1882 }
1883
1884 /**
1885 * Set cart total.
1886 *
1887 * @param float $value Value to set.
1888 */
1889 public function set_total( float $value ) {
1890 $this->totals['total'] = Formatting::format_decimal( $value, Formatting::get_price_decimals() );
1891 }
1892
1893 /**
1894 * Set total tax amount.
1895 *
1896 * @param float $value Value to set.
1897 */
1898 public function set_total_tax( float $value ) {
1899 // We round here because this is a total entry, as opposed to line items in other setters.
1900 $this->totals['total_tax'] = Formatting::round_tax_total( $value );
1901 }
1902
1903 /**
1904 * Set fee amount.
1905 *
1906 * @param float $value Value to set.
1907 */
1908 public function set_fee_total( float $value ) {
1909 $this->totals['fee_total'] = Formatting::format_decimal( $value );
1910 }
1911
1912 /**
1913 * Set fee tax.
1914 *
1915 * @param float $value Value to set.
1916 */
1917 public function set_fee_tax( float $value ) {
1918 $this->totals['fee_tax'] = $value;
1919 }
1920
1921 /**
1922 * Set taxes.
1923 *
1924 * @param array $value Tax values.
1925 */
1926 public function set_shipping_taxes( array $value ) {
1927 $this->totals['shipping_taxes'] = $value;
1928 }
1929
1930 /**
1931 * Set taxes.
1932 *
1933 * @param array $value Tax values.
1934 */
1935 public function set_cart_contents_taxes( array $value ) {
1936 $this->totals['cart_contents_taxes'] = $value;
1937 }
1938
1939 /**
1940 * Set taxes.
1941 *
1942 * @param array $value Tax values.
1943 */
1944 public function set_fee_taxes( array $value ) {
1945 $this->totals['fee_taxes'] = $value;
1946 }
1947
1948 /**
1949 * Return all calculated coupon totals.
1950 *
1951 * @return array
1952 */
1953 public function get_coupon_discount_totals(): array {
1954 return $this->coupon_discount_totals;
1955 }
1956
1957 /**
1958 * Return all calculated coupon tax totals.
1959 *
1960 * @return array
1961 */
1962 public function get_coupon_discount_tax_totals(): array {
1963 return $this->coupon_discount_tax_totals;
1964 }
1965
1966 /**
1967 * Return the per-item coupon discount breakdown.
1968 *
1969 * @return array Item Key => Coupon Code => discount amount.
1970 */
1971 public function get_coupon_discount_per_item(): array {
1972 return $this->coupon_discount_per_item;
1973 }
1974
1975 /**
1976 * Return the BOGO rewarded-unit counts.
1977 *
1978 * @return array Coupon Code => Item Key => units.
1979 */
1980 public function get_bogo_rewards(): array {
1981 return $this->bogo_rewards;
1982 }
1983
1984 /**
1985 * Return all calculated totals.
1986 *
1987 * @return array
1988 */
1989 public function get_totals(): array {
1990 return empty( $this->totals ) ? $this->default_totals : $this->totals;
1991 }
1992
1993 /**
1994 * Get a total.
1995 *
1996 * @param string $key Key of element in $totals array.
1997 *
1998 * @return mixed|float|int
1999 */
2000 protected function get_totals_var( string $key ) {
2001 return $this->totals[ $key ] ?? $this->default_totals[ $key ];
2002 }
2003
2004 /**
2005 * Get subtotal.
2006 *
2007 * @return float|string
2008 */
2009 public function get_subtotal() {
2010 $subtotal = $this->get_totals_var( 'subtotal' );
2011
2012 /**
2013 * Filter cart subtotal.
2014 *
2015 * @param float|string $subtotal Subtotal.
2016 *
2017 * @return float|string
2018 */
2019 return apply_filters( 'storeengine/cart/get_subtotal', $subtotal );
2020 }
2021
2022 /**
2023 * Get subtotal_tax.
2024 *
2025 * @return float
2026 */
2027 public function get_subtotal_tax(): float {
2028 $subtotal_tax = $this->get_totals_var( 'subtotal_tax' );
2029
2030 /**
2031 * Filter cart subtotal tax.
2032 *
2033 * @param float $subtotal_tax Subtotal tax.
2034 *
2035 * @return float
2036 */
2037 return apply_filters( 'storeengine/cart/get_subtotal_tax', $subtotal_tax );
2038 }
2039
2040 /**
2041 * Get discount_total.
2042 *
2043 * @return float
2044 */
2045 public function get_discount_total(): float {
2046 $discount_total = $this->get_totals_var( 'discount_total' );
2047
2048 /**
2049 * Filter cart discount total.
2050 *
2051 * @param float $discount_total Discount total.
2052 *
2053 * @return float
2054 */
2055 return apply_filters( 'storeengine/cart/get_discount_total', $discount_total );
2056 }
2057
2058 /**
2059 * Get discount_tax.
2060 *
2061 * @return float
2062 */
2063 public function get_discount_tax(): float {
2064 $discount_tax = $this->get_totals_var( 'discount_tax' );
2065
2066 /**
2067 * Filter cart discount tax.
2068 *
2069 * @param float $discount_tax Discount tax.
2070 *
2071 * @return float
2072 */
2073 return apply_filters( 'storeengine/cart/get_discount_tax', $discount_tax );
2074 }
2075
2076 /**
2077 * Get shipping_total.
2078 *
2079 * @return float|string
2080 */
2081 public function get_shipping_total() {
2082 $shipping_total = $this->get_totals_var( 'shipping_total' );
2083
2084 /**
2085 * Filter cart shipping total.
2086 *
2087 * @param float|string $shipping_total Shipping total.
2088 *
2089 * @return float|string
2090 */
2091 return apply_filters( 'storeengine/cart/get_shipping_total', $shipping_total );
2092 }
2093
2094 /**
2095 * Get shipping_tax.
2096 *
2097 * @return float
2098 */
2099 public function get_shipping_tax(): float {
2100 $shipping_tax = $this->get_totals_var( 'shipping_tax' );
2101
2102 /**
2103 * Filter cart shipping tax.
2104 *
2105 * @param float $shipping_tax Shipping tax.
2106 *
2107 * @return float
2108 */
2109 return apply_filters( 'storeengine/cart/get_shipping_tax', $shipping_tax );
2110 }
2111
2112 public function get_shipping_tax_total(): float {
2113 return $this->get_shipping_tax();
2114 }
2115
2116 /**
2117 * Gets cart total. This is the total of items in the cart, but after discounts. Subtotal is before discounts.
2118 *
2119 * @return float|string (can be string due to Formatting::format_decimal())
2120 */
2121 public function get_cart_contents_total() {
2122 $cart_contents_total = $this->get_totals_var( 'cart_contents_total' );
2123
2124 /**
2125 * Filter cart contents total.
2126 *
2127 * @param float $cart_contents_total Cart content total.
2128 *
2129 * @return float
2130 */
2131 return apply_filters( 'storeengine/cart/get_cart_contents_total', $cart_contents_total );
2132 }
2133
2134 /**
2135 * Gets cart tax amount.
2136 *
2137 * @return float
2138 */
2139 public function get_cart_contents_tax(): float {
2140 $cart_contents_tax = $this->get_totals_var( 'cart_contents_tax' );
2141
2142 /**
2143 * Filter cart contents total tax.
2144 *
2145 * @param float $cart_contents_tax Contents total tax.
2146 *
2147 * @return float
2148 */
2149 return apply_filters( 'storeengine/cart/get_cart_contents_tax', $cart_contents_tax );
2150 }
2151
2152 /**
2153 * Gets cart total after calculation.
2154 *
2155 * @param string $context If the context is view, the value will be formatted for display. This keeps it compatible with pre-3.2 versions.
2156 *
2157 * @return float|string
2158 */
2159 public function get_total( string $context = 'view' ) {
2160 /**
2161 * Filter get cart total.
2162 *
2163 * @param float $total Cart total.
2164 *
2165 * @return float
2166 */
2167 $total = apply_filters( 'storeengine/cart/get_total', $this->get_totals_var( 'total' ) );
2168
2169 if ( 'view' === $context ) {
2170 /**
2171 * Filter cart total.
2172 *
2173 * @param string $formatted_total Formatted Cart total.
2174 *
2175 * @return string
2176 */
2177 return apply_filters( 'storeengine/cart/total', Formatting::price( $total ) );
2178 }
2179
2180 return $total;
2181 }
2182
2183 /**
2184 * Get total tax amount.
2185 *
2186 * @return float
2187 */
2188 public function get_total_tax(): float {
2189 $total_tax = $this->get_totals_var( 'total_tax' );
2190
2191 /**
2192 * Filter cart total tax.
2193 *
2194 * @param float $total_tax Cart total tax.
2195 *
2196 * @return float
2197 */
2198 return apply_filters( 'storeengine/cart/get_total_tax', $total_tax );
2199 }
2200
2201 /**
2202 * Get total fee amount.
2203 *
2204 * @return float
2205 */
2206 public function get_fee_total(): float {
2207 $fee_total = $this->get_totals_var( 'fee_total' );
2208
2209 /**
2210 * Filter cart fee total.
2211 *
2212 * @param float $fee_total Cart fee total.
2213 *
2214 * @return float
2215 */
2216 return apply_filters( 'storeengine/cart/get_fee_total', $fee_total );
2217 }
2218
2219 /**
2220 * Get total fee tax amount.
2221 *
2222 * @return float
2223 */
2224 public function get_fee_tax(): float {
2225 $fee_total_tax = $this->get_totals_var( 'fee_tax' );
2226
2227 /**
2228 * Filter cart fee total tax.
2229 *
2230 * @param float $fee_total_tax Cart fee total tax.
2231 *
2232 * @return float
2233 */
2234 return apply_filters( 'storeengine/cart/get_fee_tax', $fee_total_tax );
2235 }
2236
2237 /**
2238 * Get taxes.
2239 */
2240 public function get_shipping_taxes(): array {
2241 $shipping_taxes = $this->get_totals_var( 'shipping_taxes' );
2242
2243 /**
2244 * Filter shipping taxes.
2245 *
2246 * @param array $shipping_taxes Cart shipping taxes.
2247 *
2248 * @return array
2249 */
2250 return apply_filters( 'storeengine/cart/get_shipping_taxes', $shipping_taxes );
2251 }
2252
2253 /**
2254 * Get taxes.
2255 */
2256 public function get_cart_contents_taxes(): array {
2257 $cart_contents_taxes = $this->get_totals_var( 'cart_contents_taxes' );
2258
2259 /**
2260 * Filter cart contents taxes.
2261 *
2262 * @param array $cart_contents_taxes Cart contents taxes.
2263 *
2264 * @return array
2265 */
2266 return apply_filters( 'storeengine/cart/get_cart_contents_taxes', $cart_contents_taxes );
2267 }
2268
2269 /**
2270 * Get taxes.
2271 */
2272 public function get_fee_taxes(): array {
2273 $fee_taxes = $this->get_totals_var( 'fee_taxes' );
2274
2275 /**
2276 * Filter cart fee taxes.
2277 *
2278 * @param array $fee_taxes Cart fee taxes.
2279 *
2280 * @return array
2281 */
2282 return apply_filters( 'storeengine/cart/get_fee_taxes', $fee_taxes );
2283 }
2284
2285 /**
2286 * Returns the cart and shipping taxes, merged.
2287 *
2288 * @return array merged taxes
2289 */
2290 public function get_taxes(): array {
2291 $taxes = Formatting::array_merge_recursive_numeric( $this->get_shipping_taxes(), $this->get_cart_contents_taxes(), $this->get_fee_taxes() );
2292
2293 /**
2294 * Filter cart taxes.
2295 *
2296 * @param array $taxes Cart taxes.
2297 * @param Cart $this Cart instance.
2298 *
2299 * @return array
2300 */
2301 return apply_filters( 'storeengine/cart/get_taxes', $taxes, $this );
2302 }
2303
2304 /**
2305 * Determines the value that the customer spent and the subtotal
2306 * displayed, used for things like coupon validation.
2307 *
2308 * Since the coupon lines are displayed based on the TAX DISPLAY value
2309 * of cart, this is used to determine the spend.
2310 *
2311 * If cart totals are shown including tax, use the subtotal.
2312 * If cart totals are shown excluding tax, use the subtotal ex tax
2313 * (tax is shown after coupons).
2314 *
2315 * @return float
2316 */
2317 public function get_displayed_subtotal(): float {
2318 return $this->display_prices_including_tax() ? $this->get_subtotal() + $this->get_subtotal_tax() : $this->get_subtotal();
2319 }
2320
2321 public function get_tax_totals() {
2322 $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.
2323 $taxes = $this->get_taxes();
2324 $tax_totals = [];
2325
2326 foreach ( $taxes as $key => $tax ) {
2327 $code = Tax::get_rate_code( $key );
2328
2329 if ( $code || apply_filters( 'storeengine/cart/remove_taxes_zero_rate_id', 'zero-rated' ) === $key ) {
2330 if ( ! isset( $tax_totals[ $code ] ) ) {
2331 $tax_totals[ $code ] = new stdClass();
2332 $tax_totals[ $code ]->amount = 0;
2333 }
2334
2335 $tax_totals[ $code ]->tax_rate_id = $key;
2336 $tax_totals[ $code ]->is_compound = Tax::is_compound( $key );
2337 $tax_totals[ $code ]->label = Tax::get_rate_label( $key );
2338
2339 if ( isset( $shipping_taxes[ $key ] ) ) {
2340 $tax -= $shipping_taxes[ $key ];
2341 // Round tax total.
2342 $tax = Formatting::round_tax_total( $tax );
2343 // Add to total amount.
2344 $tax += NumberUtil::round( $shipping_taxes[ $key ], Formatting::get_price_decimals() );
2345 unset( $shipping_taxes[ $key ] );
2346 }
2347
2348 $tax_totals[ $code ]->amount += Formatting::round_tax_total( $tax );
2349 // Set formatted amount.
2350 $tax_totals[ $code ]->formatted_amount = Formatting::price( $tax_totals[ $code ]->amount );
2351 }
2352 }
2353
2354 if ( apply_filters( 'storeengine/cart/hide_zero_taxes', true ) ) {
2355 $amounts = array_filter( wp_list_pluck( $tax_totals, 'amount' ) );
2356 $tax_totals = array_intersect_key( $tax_totals, $amounts );
2357 }
2358
2359 /**
2360 * Filter get cart tax totals.
2361 *
2362 * @param array $tax_totals Cart Tax totals.
2363 * @param Cart $this Cart instance.
2364 *
2365 * @return array
2366 */
2367 return apply_filters( 'storeengine/cart/tax_totals', $tax_totals, $this );
2368 }
2369
2370 public function get_tax_total(): float {
2371 return $this->get_fee_tax() + $this->get_cart_contents_tax();
2372 }
2373
2374 /**
2375 * Get all tax classes for items in the cart.
2376 *
2377 * @return array
2378 */
2379 public function get_cart_item_tax_classes(): array {
2380 $found_tax_classes = [];
2381
2382 foreach ( $this->get_cart_items() as $item ) {
2383 $product = Helper::get_product( $item->product_id );
2384 if ( $product && ( $product->is_taxable() || $product->is_shipping_taxable() ) ) {
2385 $found_tax_classes[] = $product->get_tax_class();
2386 }
2387 }
2388
2389 return array_unique( $found_tax_classes );
2390 }
2391
2392 /**
2393 * Get all tax classes for shipping based on the items in the cart.
2394 *
2395 * @return array
2396 */
2397 public function get_cart_item_tax_classes_for_shipping(): array {
2398 $found_tax_classes = [];
2399
2400 foreach ( $this->get_cart_items() as $item ) {
2401 $product = Helper::get_product( $item->product_id );
2402 if ( $product && $product->is_shipping_taxable() ) {
2403 $found_tax_classes[] = $product->get_tax_class();
2404 }
2405 }
2406
2407 return array_unique( $found_tax_classes );
2408 }
2409
2410 /**
2411 * Gets the cart tax (after calculation).
2412 *
2413 * @return string formatted price
2414 */
2415 public function get_cart_tax(): string {
2416 $cart_total_tax = Formatting::round_tax_total( $this->get_cart_contents_tax() + $this->get_shipping_tax() + $this->get_fee_tax() );
2417 $cart_total_tax = $cart_total_tax ? Formatting::price( $cart_total_tax ) : '';
2418
2419 /**
2420 * Filter get cart tax.
2421 *
2422 * @param string $cart_total_tax Cart total tax.
2423 *
2424 * @return string
2425 */
2426 return apply_filters( 'storeengine/cart/get_tax', $cart_total_tax );
2427 }
2428
2429 /**
2430 * Get a tax amount.
2431 *
2432 * @param string $tax_rate_id ID of the tax rate to get taxes for.
2433 *
2434 * @return float amount
2435 */
2436 public function get_tax_amount( string $tax_rate_id ) {
2437 $taxes = Formatting::array_merge_recursive_numeric( $this->get_cart_contents_taxes(), $this->get_fee_taxes() );
2438
2439 return $taxes[ $tax_rate_id ] ?? 0;
2440 }
2441
2442 /**
2443 * Get a tax amount.
2444 *
2445 * @param string $tax_rate_id ID of the tax rate to get taxes for.
2446 *
2447 * @return float amount
2448 */
2449 public function get_shipping_tax_amount( string $tax_rate_id ) {
2450 $taxes = $this->get_shipping_taxes();
2451
2452 return $taxes[ $tax_rate_id ] ?? 0;
2453 }
2454
2455 /**
2456 * Get tax row amounts with or without compound taxes includes.
2457 *
2458 * @param bool $compound True if getting compound taxes.
2459 * @param bool $display True if getting total to display.
2460 *
2461 * @return float|string total tax amount, decimal formated if display is true.
2462 */
2463 public function get_taxes_total( bool $compound = true, bool $display = true ) {
2464 $total = 0;
2465 $taxes = $this->get_taxes();
2466 foreach ( $taxes as $key => $tax ) {
2467 if ( ! $compound && Tax::is_compound( $key ) ) {
2468 continue;
2469 }
2470 $total += $tax;
2471 }
2472
2473 if ( $display ) {
2474 $total = Formatting::format_decimal( $total, Formatting::get_price_decimals() );
2475 }
2476
2477 /**
2478 * Filter cart taxes total.
2479 *
2480 * @param float|int|mixed|string $total
2481 * @param bool $compound
2482 * @param bool $display
2483 * @param Cart $this
2484 *
2485 * @return mixed
2486 */
2487 return apply_filters( 'storeengine/cart/taxes_total', $total, $compound, $display, $this );
2488 }
2489
2490 /**
2491 * Given a set of packages with rates, get the chosen ones only.
2492 *
2493 * @param array $calculated_shipping_packages Array of packages.
2494 *
2495 * @return array
2496 */
2497 protected function get_chosen_shipping_methods( array $calculated_shipping_packages = [] ): array {
2498 $chosen_methods = [];
2499 // Get chosen methods for each package to get our totals.
2500 foreach ( $calculated_shipping_packages as $key => $package ) {
2501 $chosen_method = $this->get_chosen_shipping_method_for_package( $key, $package );
2502 if ( $chosen_method ) {
2503 $chosen_methods[ $key ] = $package['rates'][ $chosen_method ];
2504 }
2505 }
2506
2507 return $chosen_methods;
2508 }
2509
2510 /**
2511 * Get chosen method for package from session.
2512 *
2513 * @param int|string $key Key of package.
2514 * @param array $package Package data array.
2515 *
2516 * @return string|bool Either the chosen method ID or false if nothing is chosen yet.
2517 */
2518 public function get_chosen_shipping_method_for_package( $key, array $package ) {
2519 $chosen_methods = $this->get_meta( 'chosen_shipping_methods' );
2520 $chosen_methods = is_array( $chosen_methods ) ? $chosen_methods : [];
2521 $chosen_method = $chosen_methods[ $key ] ?? false;
2522 $changed = $this->shipping_methods_have_changed( $key, $package );
2523
2524
2525 if ( ! isset( $package['rates'] ) || ! is_array( $package['rates'] ) ) {
2526 $package['rates'] = [];
2527 }
2528
2529 // If not set, not available, or available methods have changed, set to the DEFAULT option.
2530 if ( ! $chosen_method || $changed || ! isset( $package['rates'][ $chosen_method ] ) ) {
2531 $chosen_method = $this->get_default_shipping_method_for_package( $key, $package, $chosen_method );
2532
2533 if ( ! empty( $chosen_method ) ) {
2534 $chosen_methods[ $key ] = $chosen_method;
2535 }
2536
2537 $this->set_meta( 'chosen_shipping_methods', $chosen_methods );
2538
2539 /**
2540 * Fires when a shipping method is chosen.
2541 *
2542 * @param string $chosen_method Chosen shipping method. E.g. flat_rate:1.
2543 */
2544 do_action( 'storeengine/shipping/method_chosen', $chosen_method );
2545 }
2546
2547 return $chosen_method;
2548 }
2549
2550 /**
2551 * See if the methods have changed since the last request.
2552 *
2553 * @param int|string $key
2554 * @param array $package
2555 *
2556 * @return bool
2557 */
2558 public function shipping_methods_have_changed( $key, array $package ): bool {
2559 $previous_shipping_methods = $this->get_meta( 'previous_shipping_methods' );
2560 // Get new and old rates.
2561 $new_rates = array_keys( $package['rates'] );
2562 $prev_rates = $previous_shipping_methods[ $key ] ?? false;
2563 // Update session.
2564 $previous_shipping_methods[ $key ] = $new_rates;
2565 $this->set_meta( 'previous_shipping_methods', $previous_shipping_methods );
2566
2567 return $new_rates !== $prev_rates;
2568 }
2569
2570 /**
2571 * Choose the default method for a package.
2572 *
2573 * @param int|string $key Key of package.
2574 * @param array $package Package data array.
2575 * @param string $chosen_method Chosen shipping method. e.g. flat_rate:1.
2576 *
2577 * @return string
2578 */
2579 public function get_default_shipping_method_for_package( $key, array $package, string $chosen_method ): string {
2580 $rate_keys = array_keys( $package['rates'] );
2581
2582
2583 // Default to the first method in the package. This can be sorted in the backend by the merchant.
2584 $default = current( $rate_keys );
2585
2586 // @TODO: Check coupons to see if free shipping is available. If it is, we'll use that method as the default.
2587
2588
2589 /**
2590 * Filters the default shipping method for a package.
2591 *
2592 * @param string $default Default shipping method.
2593 * @param array $rates Shipping rates.
2594 * @param string $chosen_method Chosen method id.
2595 */
2596 return (string) apply_filters( 'storeengine/shipping/chosen_method', $default, $package['rates'], $chosen_method );
2597 }
2598
2599 public function calculate_shipping(): array {
2600 // Reset totals.
2601 $this->set_shipping_total( 0 );
2602 $this->set_shipping_tax( 0 );
2603 $this->set_shipping_taxes( [] );
2604 $this->shipping_methods = [];
2605 $this->has_calculated_shipping = false;
2606
2607 if ( ! $this->needs_shipping() || ! $this->show_shipping() ) {
2608 return $this->shipping_methods;
2609 }
2610
2611 $this->has_calculated_shipping = true;
2612 $this->shipping_methods = $this->get_chosen_shipping_methods( Shipping::init()->calculate_shipping( $this->get_shipping_packages(), $this ) );
2613
2614 $shipping_costs = wp_list_pluck( $this->shipping_methods, 'cost' );
2615 $shipping_taxes = wp_list_pluck( $this->shipping_methods, 'taxes' );
2616 $merged_taxes = [];
2617 foreach ( $shipping_taxes as $taxes ) {
2618 foreach ( $taxes as $tax_id => $tax_amount ) {
2619 $merged_taxes[ $tax_id ] = ( $merged_taxes[ $tax_id ] ?? 0 ) + $tax_amount;
2620 }
2621 }
2622
2623 $this->set_shipping_total( array_sum( $shipping_costs ) );
2624 $this->set_shipping_tax( array_sum( $merged_taxes ) );
2625 $this->set_shipping_taxes( $merged_taxes );
2626
2627 return $this->shipping_methods;
2628 }
2629
2630 /**
2631 * Will set cart cookies if needed and when possible.
2632 *
2633 * Headers are only updated if headers have not yet been sent.
2634 */
2635 public function maybe_set_cart_cookies() {
2636 if ( headers_sent() || ! did_action( 'wp_loaded' ) || isset( $this->logginout ) ) {
2637 return;
2638 }
2639
2640 if ( $this->has_items() ) {
2641 $this->set_cart_cookies( true );
2642 } elseif ( isset( $_COOKIE['storeengine_items_in_cart'] ) ) { // WPCS: input var ok.
2643 $this->set_cart_cookies( false );
2644 }
2645
2646 $this->dedupe_cookies();
2647 }
2648
2649 public function remove_cart_cookies() {
2650 $this->logginout = true;
2651 $this->set_cart_cookies( false );
2652 }
2653
2654 /**
2655 * Set cart hash cookie and items in cart if not already set.
2656 *
2657 * @param bool $set Should cookies be set (true) or unset.
2658 */
2659 private function set_cart_cookies( $set = true ) {
2660 if ( $set ) {
2661 if ( ! $this->cart_hash ) {
2662 $this->cart_hash = wp_generate_uuid4();
2663 }
2664
2665 $setcookies = [
2666 'storeengine_items_in_cart' => '1',
2667 'storeengine_cart_hash' => $this->cart_hash,
2668 ];
2669
2670 foreach ( $setcookies as $name => $value ) {
2671 if ( ! isset( $_COOKIE[ $name ] ) || $_COOKIE[ $name ] !== $value ) {
2672 Helper::setcookie( $name, $value );
2673 $_COOKIE[ $name ] = $value;
2674 }
2675 }
2676 } else {
2677 $unsetcookies = [ 'storeengine_items_in_cart', 'storeengine_cart_hash' ];
2678
2679 foreach ( $unsetcookies as $name ) {
2680 if ( isset( $_COOKIE[ $name ] ) ) {
2681 Helper::setcookie( $name, 0, time() - HOUR_IN_SECONDS );
2682 unset( $_COOKIE[ $name ] );
2683 }
2684 }
2685 }
2686
2687 do_action( 'storeengine/cart/set_cart_cookies', $set );
2688 }
2689
2690 /**
2691 * Remove duplicate cookies from the response.
2692 */
2693 private function dedupe_cookies() {
2694 $all_cookies = array_filter( headers_list(), fn( $header ) => stripos( $header, 'Set-Cookie:' ) !== false );
2695 $final_cookies = [];
2696 $update_cookies = false;
2697
2698 foreach ( $all_cookies as $cookie ) {
2699 list( , $cookie_value ) = explode( ':', $cookie, 2 );
2700 list( $cookie_name, $cookie_value ) = explode( '=', trim( $cookie_value ), 2 );
2701
2702 if ( stripos( $cookie_name, 'storeengine_' ) !== false ) {
2703 $key = $this->find_cookie_by_name( $cookie_name, $final_cookies );
2704 if ( false !== $key ) {
2705 $update_cookies = true;
2706 unset( $final_cookies[ $key ] );
2707 }
2708 }
2709
2710 $final_cookies[] = $cookie;
2711 }
2712
2713 if ( $update_cookies ) {
2714 header_remove( 'Set-Cookie' );
2715 foreach ( $final_cookies as $cookie ) {
2716 // Using header here preserves previous cookie args.
2717 header( $cookie, false );
2718 }
2719 }
2720 }
2721
2722 /**
2723 * Find a cookie by name in an array of cookies.
2724 *
2725 * @param string $cookie_name Name of the cookie to find.
2726 * @param array $cookies Array of cookies to search.
2727 *
2728 * @return int|string Key of the cookie if found, false if not.
2729 */
2730 private function find_cookie_by_name( string $cookie_name, array $cookies ) {
2731 foreach ( $cookies as $key => $cookie ) {
2732 if ( strpos( $cookie, $cookie_name ) !== false ) {
2733 return $key;
2734 }
2735 }
2736
2737 return false;
2738 }
2739
2740 public function __clone() {
2741 $fees = $this->fees->get_fees();
2742 $this->fees = new CartFees();
2743 $this->fees->set_fees( $fees );
2744 }
2745 }
2746
2747 // End of file cart.php
2748