PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.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.2.0, at includes/classes/cart.php

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