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 / abstract-order.php

abstract-order.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/classes/abstract-order.php

3,596 lines 108.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace StoreEngine\Classes;
4
5 use Exception;
6 use stdClass;
7 use StoreEngine\Classes\Cart\ItemTotals;
8 use StoreEngine\Classes\enums\ProductTaxStatus;
9 use StoreEngine\Classes\Exceptions\StoreEngineException;
10 use StoreEngine\Classes\Exceptions\StoreEngineInvalidArgumentException;
11 use StoreEngine\Classes\Exceptions\StoreEngineInvalidOrderStatusException;
12 use StoreEngine\Classes\Exceptions\StoreEngineNotFoundException;
13 use StoreEngine\Classes\Order\AbstractOrderItem;
14 use StoreEngine\Classes\Order\OrderItemCoupon;
15 use StoreEngine\Classes\Order\OrderItemFee;
16 use StoreEngine\Classes\Order\OrderItemProduct;
17 use StoreEngine\Classes\Order\OrderItemShipping;
18 use StoreEngine\Classes\Order\OrderItemTax;
19 use StoreEngine\Classes\Order\PaymentInfo;
20 use StoreEngine\Classes\OrderStatus\OrderStatus;
21 use StoreEngine\Classes\PaymentTokens\PaymentToken;
22 use StoreEngine\Utils\Caching;
23 use StoreEngine\Utils\Formatting;
24 use StoreEngine\Utils\Helper;
25 use StoreEngine\Utils\NumberUtil;
26 use StoreEngine\Utils\TaxUtil;
27 use WP_Error;
28 use WP_User;
29
30 if ( ! defined( 'ABSPATH' ) ) {
31 exit;
32 }
33
34 /**
35 * Base order model backed by a custom database table data store.
36 */
37 abstract class AbstractOrder extends AbstractEntity {
38 use ItemTotals;
39
40 protected bool $read_extra_data_separately = false;
41
42 protected string $table = 'storeengine_orders';
43
44 protected string $object_type = 'order';
45
46 protected string $meta_type = 'order';
47
48 protected array $internal_meta_keys = [
49 '_total_tax',
50 '_cart_tax',
51 '_subtotal',
52 '_total',
53 ];
54
55 protected array $meta_key_to_props = [
56 '_subtotal' => 'subtotal',
57 '_total' => 'total',
58 '_total_tax' => 'total_tax',
59 '_cart_tax' => 'cart_tax',
60 ];
61
62 /**
63 * Order core data.
64 *
65 * @var array
66 */
67 protected array $data = [
68 'status' => '',
69 'currency' => '',
70 'type' => 'order',
71 'tax_amount' => '',
72 'total_amount' => '',
73 'customer_id' => '',
74 'order_email' => '',
75 'date_created_gmt' => null,
76 'date_updated_gmt' => null,
77 'parent_order_id' => '',
78 'payment_method' => '',
79 'payment_method_title' => '',
80 'transaction_id' => '',
81 'ip_address' => '',
82 'user_agent' => '',
83 'customer_note' => '',
84 'hash' => '',
85 // Operational data of an order.
86 'operational_id' => 0,
87 'created_via' => 'store-checkout',
88 'version' => STOREENGINE_VERSION,
89 'prices_include_tax' => false,
90 'coupon_usages_are_counted' => 0,
91 'download_permission_granted' => 0,
92 'cart_hash' => '',
93 'new_order_email_sent' => 0,
94 'order_key' => '',
95 'order_stock_reduced' => 0,
96 'date_paid_gmt' => null,
97 'date_completed_gmt' => null,
98 'shipping_tax_amount' => 0.00,
99 'shipping_total_amount' => 0.00,
100 'discount_tax_amount' => 0.00,
101 'discount_total_amount' => 0.00,
102 'recorded_sales' => 1,
103 // C
104 'total' => 0.0,
105 'subtotal' => 0.0,
106 'total_tax' => 0.0,
107 'cart_tax' => 0.0,
108 ];
109
110 /**
111 * Order items array.
112 *
113 * @var AbstractOrderItem[]
114 */
115 protected array $items = [];
116
117 /**
118 * Order items that need deleting are stored here.
119 *
120 * @var AbstractOrderItem[]
121 */
122 protected array $items_to_delete = [];
123
124 protected array $readable_fields = [
125 'status',
126 'currency',
127 'type',
128 'tax_amount',
129 'total_amount',
130 'customer_id',
131 'billing_email',
132 'date_created_gmt',
133 'date_updated_gmt',
134 'parent_order_id',
135 'payment_method',
136 'payment_method_title',
137 'transaction_id',
138 'ip_address',
139 'user_agent',
140 'customer_note',
141 'hash',
142 ];
143
144 /**
145 * Mappings of order item types to groups.
146 *
147 * @var array
148 */
149 protected array $item_types_to_group = [
150 'line_item' => 'line_items',
151 'tax' => 'tax_lines',
152 'shipping' => 'shipping_lines',
153 'fee' => 'fee_lines',
154 'coupon' => 'coupon_lines',
155 ];
156
157 protected bool $allow_trash = true;
158
159 protected function read_data(): array {
160 return $this->read_db_data( $this->get_id() );
161 }
162
163 /**
164 * @param int|string|array{meta_key:string,meta_value:string,format:string}|array{cart_hash:string,customer_id:int} $value
165 * @param string $field
166 *
167 * @return array
168 * @throws StoreEngineException
169 *
170 * @FIXME move raed_db_data, get_by_key & get_by_meta to helper function
171 * so we can avoid creating 2 instances for getting an order data.
172 */
173 protected function read_db_data( $value, string $field = 'id' ): array {
174 $allowed_fields = [ 'id', 'order_key', 'meta', 'cart_hash' ];
175
176 if ( ! in_array( $field, $allowed_fields, true ) ) {
177 /* translators: %s: Field/column name. */
178 StoreEngineInvalidArgumentException::throw( sprintf( esc_html__( 'Invalid argument provided. Field %s can not be recognized.', 'storeengine' ), esc_html( $field ) ) );
179 }
180 if ( in_array( $field, [ 'cart_hash', 'meta' ], true ) && ! is_array( $value ) ) {
181 /* translators: %s: Argument type. */
182 StoreEngineInvalidArgumentException::throw( sprintf( esc_html__( 'Invalid argument provided. Value must be array {meta_value, meta_key} to be used with meta field, %s provided.', 'storeengine' ), esc_html( gettype( $value ) ) ) );
183 }
184
185 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Query already prepared & escaped.
186 if ( 'id' === $field ) {
187 $result = $this->wpdb->get_row(
188 $this->wpdb->prepare(
189 "{$this->query()} WHERE o.id = %d AND o.type = %s",
190 absint( $value ),
191 $this->get_type()
192 ),
193 ARRAY_A
194 );
195 } elseif ( 'order_key' === $field ) {
196 $result = $this->wpdb->get_row(
197 $this->wpdb->prepare(
198 "{$this->query()} WHERE p.order_key = %s AND o.type = %s",
199 $value,
200 $this->get_type()
201 ),
202 ARRAY_A
203 );
204 } elseif ( 'cart_hash' === $field ) {
205 list( $cart_hash, $customer_id ) = $value;
206 if ( $customer_id ) {
207 $result = $this->wpdb->get_row(
208 $this->wpdb->prepare(
209 "{$this->query()} WHERE o.status = %s AND (o.customer_id = %d OR p.cart_hash = %s) ORDER BY o.id DESC",
210 'draft',
211 absint( $customer_id ),
212 $cart_hash
213 ), ARRAY_A );
214 } else {
215 $result = $this->wpdb->get_row(
216 $this->wpdb->prepare(
217 "{$this->query()} WHERE o.status = %s AND p.cart_hash = %s ORDER BY o.id DESC",
218 'draft',
219 $cart_hash
220 ), ARRAY_A );
221 }
222 } else {
223 if ( ! empty( $value[2] ) && is_string( $value[2] ) ) {
224 $format = $value[2];
225 } else {
226 $format = is_float( $value[0] ) ? '%f' : ( is_numeric( $value[0] ) ? '%d' : '%s' );
227 }
228
229 $result = $this->wpdb->get_row(
230 $this->wpdb->prepare(
231 "{$this->query()} WHERE m.meta_value = $format AND m.meta_key = %s AND o.type = %s GROUP BY o.id",
232 $value[1], // meta_value
233 $value[0], // meta_key
234 $this->get_type()
235 ),
236 ARRAY_A
237 );
238 }
239
240 // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Query already prepared & escaped.
241
242 // Due to the group & concat (in the query), db can return row with null values (in all column) if order not found.
243
244 if ( ! $result || empty( $result['o_id'] ) ) {
245 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
246 $data = [
247 'field' => $field,
248 'value' => $value,
249 ];
250 } else {
251 $data = null;
252 }
253 if ( $this->wpdb->last_error ) {
254 /* translators: %s: Error message. */
255 throw new StoreEngineException( sprintf( esc_html__( 'Error reading data from database. Error: %s', 'storeengine' ), esc_html( $this->wpdb->last_error ) ), 'db-read-error', $data, 500 ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
256 } else {
257 throw new StoreEngineNotFoundException( esc_html__( 'Order not found.', 'storeengine' ), $data ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
258 }
259 }
260
261 $result['id'] = $result['o_id'];
262
263 unset( $result['o_id'], $result['order_id'] );
264
265 if ( ! $this->get_id() ) {
266 $this->set_id( $result['id'] );
267 }
268
269 if ( ! empty( $result['meta_data'] ) ) {
270 $meta_data = array_values( (array) ( json_decode( $result['meta_data'], false ) ?? [] ) );
271 $raw_meta_data = $this->filter_raw_meta_data( $meta_data );
272 if ( is_array( $raw_meta_data ) ) {
273 $this->init_meta_data( $raw_meta_data );
274 if ( ! empty( $this->cache_group ) ) {
275 wp_cache_set( $this->get_meta_cache_key(), $raw_meta_data, $this->cache_group );
276 }
277 }
278
279 foreach ( array_filter( $meta_data, [ $this, 'include_extra_meta_keys' ] ) as $meta ) {
280 if ( ! empty( $this->meta_key_to_props[ $meta->meta_key ] ) ) {
281 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- not running query, just decoding value.
282 $result[ $this->meta_key_to_props[ $meta->meta_key ] ] = maybe_unserialize( $meta->meta_value );
283 }
284 }
285
286 unset( $result['meta_data'] );
287 }
288
289 return array_merge(
290 [
291 'total' => $this->get_metadata( '_total' ),
292 'subtotal' => $this->get_metadata( '_subtotal' ),
293 'total_tax' => $this->get_metadata( '_total_tax' ),
294 'cart_tax' => $this->get_metadata( '_cart_tax' ),
295 ],
296 $result
297 );
298 }
299
300 /**
301 * @throws StoreEngineException
302 */
303 public function get_by_key( string $key ): self {
304 try {
305 $id = wp_cache_get( 'order:key:' . $key, $this->cache_group );
306
307 if ( false !== $id && false !== wp_cache_get( $id, $this->cache_group ) ) {
308 $this->set_id( $id );
309 $this->read();
310
311 return $this;
312 }
313
314 $data = $this->read_db_data( $key, 'order_key' );
315
316 wp_cache_set( 'order:key:' . $key, $data['id'], $this->cache_group );
317 wp_cache_set( $data['id'], $data, $this->cache_group );
318
319 $this->set_id( $data['id'] );
320 $this->read();
321
322 return $this;
323 } catch ( StoreEngineException $e ) {
324 if ( StoreEngineNotFoundException::WP_ERROR_CODE === $e->get_wp_error_code() ) {
325 $e->set_message( esc_html__( 'The order is no longer exists.', 'storeengine' ) );
326 }
327
328 throw $e;
329 }
330 }
331
332 /**
333 * @param string $meta_key
334 * @param string|int|float $meta_value
335 * @param string|null $format
336 *
337 * @return $this
338 * @throws StoreEngineException|StoreEngineInvalidArgumentException
339 */
340 public function get_by_meta( string $meta_key, $meta_value, ?string $format = null ): self {
341 if ( ! is_scalar( $meta_value ) ) {
342 /* translators: %s: Argument type. */
343 StoreEngineInvalidArgumentException::throw( sprintf( esc_html__( 'Invalid argument provided. Value (meta_value) must be string, int or float, %s provided.', 'storeengine' ), esc_html( gettype( $meta_value ) ) ) );
344 }
345
346 if ( is_bool( $meta_value ) ) {
347 $meta_value = (int) $meta_value;
348 }
349
350 $cache_key = 'order:meta:' . $meta_key . $meta_value;
351 $id = wp_cache_get( $cache_key, $this->cache_group );
352
353 if ( false !== $id && false !== wp_cache_get( $id, $this->cache_group ) ) {
354 $this->set_id( $id );
355 $this->read();
356
357 return $this;
358 }
359
360 if ( null === $format ) {
361 $format = is_float( $meta_value ) ? '%f' : ( is_numeric( $meta_value ) ? '%d' : '%s' );
362 }
363
364 $data = $this->read_db_data( [ $meta_key, $meta_value, $format ], 'meta' );
365 wp_cache_set( $cache_key, $data['id'], $this->cache_group );
366 wp_cache_set( $data['id'], $data, $this->cache_group );
367
368 $this->set_id( $data['id'] );
369 $this->read();
370
371 return $this;
372 }
373
374 protected function query(): ?string {
375 global $wpdb;
376
377 return "
378 SELECT
379 o.id as o_id,
380 o.parent_order_id as parent_order_id,
381 o.*,
382 p.id as operational_id,
383 b.first_name as billing_first_name,
384 b.last_name as billing_last_name,
385 b.company as billing_company,
386 b.address_1 as billing_address_1,
387 b.address_2 as billing_address_2,
388 b.city as billing_city,
389 b.state as billing_state,
390 b.postcode as billing_postcode,
391 b.country as billing_country,
392 o.billing_email as order_email,
393 b.email as billing_email,
394 b.phone as billing_phone,
395 s.first_name as shipping_first_name,
396 s.last_name as shipping_last_name,
397 s.company as shipping_company,
398 s.address_1 as shipping_address_1,
399 s.address_2 as shipping_address_2,
400 s.city as shipping_city,
401 s.state as shipping_state,
402 s.postcode as shipping_postcode,
403 s.country as shipping_country,
404 s.email as shipping_email,
405 s.phone as shipping_phone,
406 p.*
407 FROM {$wpdb->prefix}storeengine_orders o
408 LEFT JOIN {$wpdb->prefix}storeengine_order_addresses b ON b.order_id = o.id AND b.address_type = 'billing'
409 LEFT JOIN {$wpdb->prefix}storeengine_order_addresses s ON s.order_id = o.id AND s.address_type = 'shipping'
410 LEFT JOIN {$wpdb->prefix}storeengine_order_operational_data p ON p.order_id = o.id
411 LEFT JOIN {$wpdb->prefix}storeengine_orders_meta m ON m.order_id = o.id
412 ";
413 }
414
415 protected function prepare_for_db( string $context = 'create' ): array {
416 $data = [];
417 $format = [];
418
419 $props = [
420 'status',
421 'currency',
422 'type',
423 'tax_amount',
424 'total_amount',
425 'customer_id',
426 'order_email',
427 'date_created_gmt',
428 'date_updated_gmt',
429 'parent_order_id',
430 'payment_method',
431 'payment_method_title',
432 'transaction_id',
433 'ip_address',
434 'user_agent',
435 'customer_note',
436 'hash',
437 ];
438
439 if ( 'create' === $context ) {
440 if ( ! $this->get_date_created_gmt( 'edit' ) ) {
441 $this->set_date_created_gmt( current_time( 'mysql', 1 ) );
442 }
443 }
444
445 // Always set.
446 if ( ! $this->get_date_updated_gmt( 'edit' ) ) {
447 $this->set_date_updated_gmt( current_time( 'mysql', 1 ) );
448 }
449
450 foreach ( $props as $prop ) {
451 if ( 'update' === $context && 'date_created_gmt' === $prop ) {
452 continue;
453 }
454
455 $value = $this->{"get_$prop"}( 'edit' );
456
457 if ( $value && is_a( $value, StoreengineDatetime::class ) ) {
458 $value = $this->prepare_date_for_db( $value );
459 }
460
461 if ( 'order_email' === $prop ) {
462 $prop = 'billing_email'; // Special case.
463 // @TODO rename the column as order_email.
464 }
465
466 $format[] = $this->predict_format( $prop, $value );
467 $data[ $prop ] = $value;
468 }
469
470 return [
471 'data' => apply_filters( 'storeengine/' . $this->object_type . '/db/' . $context, $data, $this ),
472 'format' => $format,
473 ];
474 }
475
476 protected function prepare_operational_data_for_db( string $context = 'create' ): array {
477 $data = [];
478 $format = [];
479
480 $props = [
481 'created_via' => 'created_via',
482 'storeengine_version' => 'version',
483 'prices_include_tax' => 'prices_include_tax',
484 'coupon_usages_are_counted' => 'coupon_usages_are_counted',
485 'download_permission_granted' => 'download_permission_granted',
486 'cart_hash' => 'cart_hash',
487 'new_order_email_sent' => 'new_order_email_sent',
488 'order_key' => 'order_key',
489 'date_paid_gmt' => 'date_paid_gmt',
490 'date_completed_gmt' => 'date_completed_gmt',
491 'shipping_tax_amount' => 'shipping_tax_amount',
492 'shipping_total_amount' => 'shipping_total_amount',
493 'discount_tax_amount' => 'discount_tax_amount',
494 'discount_total_amount' => 'discount_total_amount',
495 'recorded_sales' => 'recorded_sales',
496 ];
497
498 if ( 'create' === $context ) {
499 $props['order_id'] = 'id';
500 }
501
502 foreach ( $props as $key => $prop ) {
503 $value = $this->{"get_$prop"}( 'edit' );
504
505 if ( $value && is_a( $value, StoreengineDatetime::class ) ) {
506 $value = $this->prepare_date_for_db( $value );
507 }
508
509 $format[] = $this->predict_format( $key, $value );
510 $data[ $key ] = $value;
511 }
512
513 return [
514 'data' => apply_filters( 'storeengine/' . $this->object_type . '_operational_data/db/' . $context, $data, $this ),
515 'format' => $format,
516 ];
517 }
518
519 /**
520 * @throws StoreEngineException
521 */
522 public function create() {
523 $this->set_version( STOREENGINE_VERSION );
524 $this->set_currency( $this->get_currency() ? $this->get_currency() : Formatting::get_currency() );
525
526 if ( ! $this->get_date_created_gmt( 'edit' ) ) {
527 $this->set_date_created_gmt( current_time( 'mysql', 1 ) );
528 }
529
530 if ( ! $this->get_order_key( 'edit' ) ) {
531 $this->set_order_key( self::generate_order_key() );
532 }
533
534 parent::create();
535
536 $this->save_items();
537
538 [ 'data' => $data, 'format' => $format ] = $this->prepare_operational_data_for_db( 'create' );
539
540 if ( $this->wpdb->insert( "{$this->wpdb->prefix}storeengine_order_operational_data", $data, $format ) ) {
541 $this->set_operational_id( $this->wpdb->insert_id );
542 }
543
544 if ( $this->wpdb->last_error ) {
545 throw new StoreEngineException( wp_kses_post( $this->wpdb->last_error ), 'db-error-insert-record' );
546 }
547 }
548
549 /**
550 * @throws StoreEngineException
551 */
552 public function update() {
553 parent::update();
554
555 $this->save_items();
556
557 [ 'data' => $data, 'format' => $format ] = $this->prepare_operational_data_for_db( 'update' );
558
559 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
560 $this->wpdb->update(
561 "{$this->wpdb->prefix}storeengine_order_operational_data",
562 $data,
563 [ 'order_id' => $this->get_id() ],
564 $format,
565 [ '%d' ]
566 );
567
568 if ( $this->wpdb->last_error ) {
569 throw new StoreEngineException( wp_kses_post( $this->wpdb->last_error ), 'db-error-update-record' );
570 }
571
572 $this->apply_changes();
573 $this->clear_cache();
574 }
575
576 public function delete( bool $force_delete = false ): bool {
577 if ( ! $force_delete && $this->is_trashable() ) {
578 return $this->trash();
579 }
580
581 if ( $this->get_operational_id( 'edit' ) && $this->get_id() ) {
582 $this->wpdb->delete(
583 "{$this->wpdb->prefix}storeengine_order_operational_data",
584 [ 'order_id' => $this->get_id() ],
585 [ '%d' ],
586 );
587
588 if ( $this->wpdb->last_error ) {
589 throw new StoreEngineException( wp_kses_post( $this->wpdb->last_error ), 'db-error-delete-order_operational_data' );
590 }
591 }
592
593 return parent::delete( true );
594 }
595
596 /**
597 * This method overwrites the base class's clone method to make it a no-op. In the base data object, we are unsetting the meta_id to clone.
598 * It seems like this was done to avoid conflicting the metadata when duplicating products. However, doing that does not seems necessary for orders.
599 * In-fact, when we do that for orders, we lose the capability to clone orders with custom meta data by caching plugins. This is because, when we clone an order object for caching, it will clone the metadata without the ID. Unfortunately, when this cached object with nulled meta ID is retrieved, the base data object will consider it as a new meta and will insert it as a new meta-data causing duplicates.
600 *
601 * Eventually, we should move away from overwriting the __clone method in base class itself, since it's easily possible to still duplicate the product without having to hook into the __clone method.
602 */
603 public function __clone() {
604 }
605
606 /**
607 * Get all class data in array format.
608 *
609 * @return array
610 */
611 public function get_data(): array {
612 return array_merge(
613 [ 'id' => $this->get_id() ],
614 $this->data,
615 [
616 'meta_data' => $this->get_meta_data(),
617 'line_items' => $this->get_line_product_items(),
618 'tax_lines' => $this->get_line_tax_items(),
619 'shipping_lines' => $this->get_line_shipping_items(),
620 'fee_lines' => $this->get_line_fee_items(),
621 'coupon_lines' => $this->get_line_coupon_items(),
622 ]
623 );
624 }
625
626 /**
627 * Log an error about this order is exception is encountered.
628 *
629 * @param StoreEngineException $e Exception object.
630 * @param string $message Message regarding exception thrown.
631 */
632 protected function handle_exception( StoreEngineException $e, string $message = 'Error' ) {
633 Helper::log_error( $e );
634 }
635
636 /**
637 * Save all order items which are part of this order.
638 *
639 * @throws StoreEngineException
640 */
641 protected function save_items() {
642 $items_changed = false;
643
644 foreach ( $this->items_to_delete as $item ) {
645 $item->delete();
646 $items_changed = true;
647 }
648
649 $this->items_to_delete = [];
650
651 // Add/save items.
652 foreach ( $this->items as $item_group => $items ) {
653 if ( is_array( $items ) ) {
654 $items = array_filter( $items );
655 foreach ( $items as $item_key => $item ) {
656 $item->set_order_id( $this->get_id() );
657
658 $item_id = $item->save();
659
660 // If ID changed (new item saved to DB)...
661 if ( $item_id !== $item_key ) {
662 $this->items[ $item_group ][ $item_id ] = $item;
663
664 unset( $this->items[ $item_group ][ $item_key ] );
665
666 $items_changed = true;
667 }
668 }
669 }
670 }
671
672 if ( $items_changed ) {
673 delete_transient( 'storeengine/order_' . $this->get_id() . '_needs_processing' );
674 }
675 }
676
677 /*
678 |--------------------------------------------------------------------------
679 | Getters
680 |--------------------------------------------------------------------------
681 */
682
683 /**
684 * Get parent order ID.
685 *
686 * @param string $context View or edit context.
687 *
688 * @return int
689 */
690 public function get_parent_order_id( string $context = 'view' ): int {
691 return (int) $this->get_prop( 'parent_order_id', $context );
692 }
693
694 public function get_parent_id( string $context = 'view' ): int {
695 return $this->get_parent_order_id( $context );
696 }
697
698 /**
699 * @param string $context
700 *
701 * @return false|Order
702 */
703 public function get_parent_order( string $context = 'view' ) {
704 $order = Helper::get_order( $this->get_parent_order_id( $context ) );
705
706 if ( $order && ! is_wp_error( $order ) ) {
707 return $order;
708 }
709
710 return false;
711 }
712
713 public function get_operational_id( string $context = 'view' ): int {
714 return (int) $this->get_prop( 'operational_id', $context );
715 }
716
717 /**
718 * Gets order currency.
719 *
720 * @param string $context View or edit context.
721 *
722 * @return string
723 */
724 public function get_currency( string $context = 'view' ): ?string {
725 $currency = (string) $this->get_prop( 'currency', $context );
726 if ( ! $currency ) {
727 /**
728 * In view context, return the default status if no status has been set.
729 *
730 * @param string $status Default status.
731 */
732 $currency = Formatting::get_currency();
733 }
734 return apply_filters( 'storeengine/default_order_currency', $currency );
735 }
736
737 /**
738 * Get order_version.
739 *
740 * @param string $context View or edit context.
741 *
742 * @return ?string
743 */
744 public function get_version( string $context = 'view' ): ?string {
745 return $this->get_prop( 'version', $context );
746 }
747
748 /**
749 * Get prices_include_tax.
750 *
751 * @param string $context View or edit context.
752 *
753 * @return bool
754 */
755 public function get_prices_include_tax( string $context = 'view' ): bool {
756 return Formatting::string_to_bool( $this->get_prop( 'prices_include_tax', $context ) );
757 }
758
759 /**
760 * Get date_created.
761 *
762 * @param string $context View or edit context.
763 *
764 * @return StoreEngineDateTime|NULL object if the date is set or null if there is no date.
765 *
766 * @deprecated
767 * @see self::get_date_created_gmt()
768 */
769 public function get_date_created( string $context = 'view' ): ?StoreEngineDateTime {
770 return $this->get_date_created_gmt( $context );
771 }
772
773 /**
774 * Get date_modified.
775 *
776 * @param string $context View or edit context.
777 *
778 * @return StoreEngineDateTime|NULL object if the date is set or null if there is no date.
779 */
780 public function get_date_modified( string $context = 'view' ): ?StoreEngineDateTime {
781 return $this->get_date_updated_gmt( $context );
782 }
783
784 /**
785 * Get date paid.
786 *
787 * @param string $context What the value is for. Valid values are view and edit.
788 *
789 * @return StoreengineDatetime|NULL object if the date is set or null if there is no date.
790 *
791 * @deprecated
792 */
793 public function get_date_paid( string $context = 'view' ): ?StoreEngineDateTime {
794 return $this->get_date_paid_gmt( $context );
795 }
796
797 /**
798 * Placeholder for reminding devs to use the _gmt version.
799 *
800 * @param string $context View or edit context.
801 *
802 * @return StoreEngineDateTime|NULL object if the date is set or null if there is no date.
803 * @deprecated
804 * @see set_date_completed_gmt()
805 */
806 public function get_date_completed( string $context = 'view' ): ?StoreEngineDateTime {
807 return $this->get_date_completed_gmt( $context );
808 }
809
810 /**
811 * Return the order statuses without wc- internal prefix.
812 *
813 * @param string $context View or edit context.
814 *
815 * @return string
816 */
817 public function get_status( string $context = 'view' ): ?string {
818 $status = $this->get_prop( 'status', $context );
819
820 if ( empty( $status ) && 'view' === $context ) {
821 /**
822 * In view context, return the default status if no status has been set.
823 *
824 * @param string $status Default status.
825 */
826 $status = apply_filters( 'storeengine/default_order_status', OrderStatus::PAYMENT_PENDING );
827 }
828
829 return $status;
830 }
831
832 /**
833 * Get discount_total.
834 *
835 * @param string $context View or edit context.
836 *
837 * @return string|float
838 */
839 public function get_discount_total( string $context = 'view' ) {
840 return $this->get_prop( 'discount_total_amount', $context );
841 }
842
843 /**
844 * Get discount_tax.
845 *
846 * @param string $context View or edit context.
847 *
848 * @return string|float
849 */
850 public function get_discount_tax( string $context = 'view' ) {
851 return $this->get_prop( 'discount_tax_amount', $context );
852 }
853
854 /**
855 * Get shipping_total.
856 *
857 * @param string $context View or edit context.
858 *
859 * @return string|float
860 */
861 public function get_shipping_total( string $context = 'view' ) {
862 return $this->get_prop( 'shipping_total_amount', $context );
863 }
864
865 /**
866 * Get shipping_tax.
867 *
868 * @param string $context View or edit context.
869 *
870 * @return string|float
871 */
872 public function get_shipping_tax( string $context = 'view' ) {
873 return $this->get_prop( 'shipping_tax_amount', $context );
874 }
875
876 /**
877 * Gets cart tax amount.
878 *
879 * @param string $context View or edit context.
880 *
881 * @return string|float
882 */
883 public function get_cart_tax( string $context = 'view' ) {
884 return $this->get_prop( 'cart_tax', $context );
885 }
886
887 /**
888 * Gets order grand total including taxes, shipping cost, fees, and coupon discounts. Used in gateways.
889 *
890 * @param string $context View or edit context.
891 *
892 * @return string|float
893 */
894 public function get_total( string $context = 'view' ) {
895 $value = $this->get_prop( 'total', $context );
896 if ( '' === $value ) {
897 return $value;
898 }
899
900 return (float) $value;
901 }
902
903 /**
904 * Get total tax amount. Alias for get_order_tax().
905 *
906 * @param string $context View or edit context.
907 *
908 * @return string|float
909 */
910 public function get_total_tax( string $context = 'view' ) {
911 return $this->get_prop( 'total_tax', $context );
912 }
913
914 /*
915 |--------------------------------------------------------------------------
916 | Non-CRUD Getters
917 |--------------------------------------------------------------------------
918 */
919
920 /**
921 * Gets the total discount amount.
922 *
923 * @param bool $ex_tax Show discount excl any tax.
924 *
925 * @return float
926 */
927 public function get_total_discount( bool $ex_tax = true ): float {
928 if ( $ex_tax ) {
929 $total_discount = (float) $this->get_discount_total();
930 } else {
931 $total_discount = (float) $this->get_discount_total() + (float) $this->get_discount_tax();
932 }
933
934 return apply_filters( 'storeengine/order_get_total_discount', NumberUtil::round( $total_discount, Formatting::get_rounding_precision() ), $this );
935 }
936
937 /**
938 * Gets order subtotal. Order subtotal is the price of all items excluding taxes, fees, shipping cost, and coupon discounts.
939 * If sale price is set on an item, the subtotal will include this sale discount. E.g. a product with a regular
940 * price of $100 bought at a 50% discount will represent $50 of the subtotal for the order.
941 *
942 * @return float
943 */
944 public function get_subtotal(): float {
945 $subtotal = NumberUtil::round( $this->get_cart_subtotal_for_order(), Formatting::get_price_decimals() );
946
947 return apply_filters( 'storeengine/order_get_subtotal', $subtotal, $this );
948 }
949
950 /**
951 * Get taxes, merged by code, formatted ready for output.
952 *
953 * @return array
954 */
955 public function get_tax_totals(): array {
956 $tax_totals = [];
957
958 foreach ( $this->get_line_tax_items() as $key => $tax ) {
959 $code = $tax->get_rate_code();
960
961 if ( ! isset( $tax_totals[ $code ] ) ) {
962 $tax_totals[ $code ] = new stdClass();
963 $tax_totals[ $code ]->amount = 0;
964 }
965
966
967 $tax_totals[ $code ]->id = $key;
968 $tax_totals[ $code ]->code = $code;
969 $tax_totals[ $code ]->rate_id = $tax->get_rate_id();
970 $tax_totals[ $code ]->is_compound = $tax->is_compound();
971 $tax_totals[ $code ]->label = $tax->get_label();
972 $tax_totals[ $code ]->amount += (float) $tax->get_tax_total() + (float) $tax->get_shipping_tax_total();
973 // Add formated amount.
974 $tax_totals[ $code ]->formatted_amount = Formatting::price( $tax_totals[ $code ]->amount, [ 'currency' => $this->get_currency() ] );
975 }
976
977 if ( apply_filters( 'storeengine/order_hide_zero_taxes', true ) ) {
978 $amounts = array_filter( wp_list_pluck( $tax_totals, 'amount' ) );
979 $tax_totals = array_intersect_key( $tax_totals, $amounts );
980 }
981
982 return apply_filters( 'storeengine/order_get_tax_totals', $tax_totals, $this );
983 }
984
985 /**
986 * Get all valid statuses for this order
987 *
988 * @return array Internal status keys e.g. 'processing'
989 */
990 protected function get_valid_statuses(): array {
991 return array_keys( OrderStatus::get_order_statuses() );
992 }
993
994 /**
995 * Alias for get_customer_id().
996 *
997 * @param string $context What the value is for. Valid values are view and edit.
998 *
999 * @return int
1000 */
1001 public function get_user_id( string $context = 'view' ): int {
1002 return $this->get_customer_id( $context );
1003 }
1004
1005 /**
1006 * Get the user associated with the order. False for guests.
1007 *
1008 * @return WP_User|false
1009 */
1010 public function get_user() {
1011 return $this->get_user_id() ? get_user_by( 'id', $this->get_user_id() ) : false;
1012 }
1013
1014 /**
1015 * Gets information about whether coupon counts were updated.
1016 *
1017 * @param string $context What the value is for. Valid values are view and edit.
1018 *
1019 * @return bool True if coupon counts were updated, false otherwise.
1020 */
1021 public function get_recorded_coupon_usage_counts( string $context = 'view' ): bool {
1022 return Formatting::string_to_bool( $this->get_prop( 'recorded_coupon_usage_counts', $context ) );
1023 }
1024
1025 /**
1026 * Get basic order data in array format.
1027 *
1028 * @return array
1029 */
1030 public function get_base_data(): array {
1031 return array_merge(
1032 [ 'id' => $this->get_id() ],
1033 $this->data
1034 );
1035 }
1036
1037 /**
1038 * Get info about the card used for payment in the order.
1039 *
1040 * @return array
1041 */
1042 public function get_payment_card_info(): array {
1043 return PaymentInfo::get_card_info( $this );
1044 }
1045
1046 /*
1047 |--------------------------------------------------------------------------
1048 | Setters
1049 |--------------------------------------------------------------------------
1050 |
1051 | Functions for setting order data. These should not update anything in the
1052 | database itself and should only change what is stored in the class
1053 | object. However, for backwards compatibility pre 3.0.0 some of these
1054 | setters may handle both.
1055 */
1056
1057 /**
1058 * Set parent order ID.
1059 *
1060 * @param int|string $value Value to set.
1061 *
1062 * @throws StoreEngineException Exception thrown if parent ID does not exist or is invalid.
1063 */
1064 public function set_parent_order_id( $value ) {
1065 $value = absint( $value );
1066 if ( $value && $value === $this->get_id() ) {
1067 $this->error( 'order_invalid_parent_order_id', __( 'Invalid parent ID', 'storeengine' ) );
1068 }
1069 $this->set_prop( 'parent_order_id', $value );
1070 }
1071
1072 /**
1073 * Set parent order ID.
1074 *
1075 * @param int|string $value Value to set.
1076 */
1077 public function set_operational_id( $value ) {
1078 $this->set_prop( 'operational_id', absint( $value ) );
1079 }
1080
1081 /**
1082 * Set order status.
1083 *
1084 * @param string $new_status Status to change the order to. No internal wc- prefix is required.
1085 *
1086 * @return array details of change
1087 */
1088 public function set_status( string $new_status ): array {
1089 $old_status = $this->get_status();
1090
1091 if ( $new_status === $old_status ) {
1092 return [
1093 'from' => $old_status,
1094 'to' => $new_status,
1095 ];
1096 }
1097
1098 $status_exceptions = [ OrderStatus::AUTO_DRAFT, OrderStatus::TRASH ];
1099
1100 // If setting the status, ensure it's set to a valid status.
1101 if ( true === $this->object_read ) {
1102 // Only allow valid new status.
1103 if (
1104 ! in_array( $new_status, $this->get_valid_statuses(), true ) &&
1105 ! in_array( $new_status, $status_exceptions, true )
1106 ) {
1107 $new_status = OrderStatus::DRAFT;
1108 }
1109
1110 // If the old status is set but unknown (e.g. draft) assume it's pending for action usage.
1111 if (
1112 $old_status &&
1113 (
1114 OrderStatus::AUTO_DRAFT === $old_status ||
1115 (
1116 ! in_array( $old_status, $this->get_valid_statuses(), true ) &&
1117 ! in_array( $old_status, $status_exceptions, true )
1118 )
1119 )
1120 ) {
1121 $old_status = OrderStatus::DRAFT;
1122 }
1123 }
1124
1125 $this->set_prop( 'status', $new_status );
1126
1127 return [
1128 'from' => $old_status,
1129 'to' => $new_status,
1130 ];
1131 }
1132
1133 /**
1134 * Set order_version.
1135 *
1136 * @param string $value Value to set.
1137 */
1138 public function set_version( string $value ) {
1139 $this->set_prop( 'version', $value );
1140 }
1141
1142 /**
1143 * Set order_currency.
1144 *
1145 * @param ?string $value Value to set.
1146 *
1147 * @throws StoreEngineException Exception may be thrown if value is invalid.
1148 */
1149 public function set_currency( ?string $value = null ) {
1150 if ( $value && ! in_array( $value, array_keys( Helper::get_currencies() ), true ) ) {
1151 $this->error( 'order_invalid_currency', __( 'Invalid currency code', 'storeengine' ) );
1152 }
1153
1154 $this->set_prop( 'currency', $value ?: Formatting::get_currency() );
1155 }
1156
1157 /**
1158 * Set prices_include_tax.
1159 *
1160 * @param bool|string $value Value to set.
1161 */
1162 public function set_prices_include_tax( $value ) {
1163 $this->set_prop( 'prices_include_tax', Formatting::string_to_bool( $value ) );
1164 }
1165
1166 /**
1167 * Set date_created.
1168 *
1169 * @param string|integer|null $date UTC timestamp, or ISO 8601 DateTime. If the DateTime string has no timezone or offset, WordPress site timezone will be assumed. Null if there is no date.
1170 */
1171 public function set_date_created( $date = null ) {
1172 $this->set_date_created_gmt( $date );
1173 }
1174
1175 /**
1176 * Set date_modified.
1177 *
1178 * @param string|integer|null $date UTC timestamp, or ISO 8601 DateTime. If the DateTime string has no timezone or offset, WordPress site timezone will be assumed. Null if there is no date.
1179 */
1180 public function set_date_modified( $date = null ) {
1181 $this->set_date_updated_gmt( $date );
1182 }
1183
1184 /**
1185 * Set discount_total.
1186 *
1187 * @param string|float $value Value to set.
1188 */
1189 public function set_discount_total( $value ) {
1190 $this->set_prop( 'discount_total_amount', Formatting::format_decimal( $value, false, true ) );
1191 }
1192
1193 /**
1194 * Set discount_tax.
1195 *
1196 * @param string|float $value Value to set.
1197 */
1198 public function set_discount_tax( $value ) {
1199 $this->set_prop( 'discount_tax_amount', Formatting::format_decimal( $value, false, true ) );
1200 }
1201
1202 /**
1203 * Set shipping_total.
1204 *
1205 * @param string|float $value Value to set.
1206 */
1207 public function set_shipping_total( $value ) {
1208 $this->set_prop( 'shipping_total_amount', Formatting::format_decimal( $value, false, true ) );
1209 }
1210
1211 /**
1212 * Set shipping_tax.
1213 *
1214 * @param string|float $value Value to set.
1215 */
1216 public function set_shipping_tax( $value ) {
1217 $this->set_prop( 'shipping_tax_amount', Formatting::format_decimal( $value, false, true ) );
1218 $this->set_total_tax( (float) $this->get_cart_tax() + (float) $this->get_shipping_tax() );
1219 }
1220
1221 /**
1222 * Set cart tax.
1223 *
1224 * @param string|float $value Value to set.
1225 */
1226 public function set_cart_tax( $value ) {
1227 $this->set_prop( 'cart_tax', Formatting::format_decimal( $value, false, true ) );
1228 $this->set_total_tax( (float) $this->get_cart_tax() + (float) $this->get_shipping_tax() );
1229 }
1230
1231 /**
1232 * Sets order tax (sum of cart and shipping tax). Used internally only.
1233 *
1234 * @param string|float $value Value to set.
1235 */
1236 protected function set_total_tax( $value ) {
1237 // We round here because this is a total entry, as opposed to line items in other setters.
1238 $this->set_prop( 'total_tax', Formatting::format_decimal( NumberUtil::round( $value, Formatting::get_price_decimals() ) ) );
1239 }
1240
1241 /**
1242 * Set total.
1243 *
1244 * @param string|float|int $value Value to set.
1245 */
1246 public function set_total( $value ) {
1247 $this->set_prop( 'total', Formatting::format_decimal( $value, Formatting::get_price_decimals() ) );
1248 }
1249
1250
1251 /**
1252 * Stores information about whether the coupon usage were counted.
1253 *
1254 * @param bool|string $value True if counted, false if not.
1255 *
1256 * @return void
1257 */
1258 public function set_recorded_coupon_usage_counts( $value ) {
1259 $this->set_prop( 'recorded_coupon_usage_counts', Formatting::string_to_bool( $value ) );
1260 }
1261
1262 /*
1263 |--------------------------------------------------------------------------
1264 | Order Item Handling
1265 |--------------------------------------------------------------------------
1266 |
1267 | Order items are used for products, taxes, shipping, and fees within
1268 | each order.
1269 */
1270
1271 /**
1272 * Remove all line items (products, coupons, shipping, taxes) from the order.
1273 *
1274 * @param ?string $type Order item type. Default null.
1275 */
1276 public function remove_order_items( ?string $type = null ) {
1277
1278 /**
1279 * Trigger action before removing all order line items. Allows you to track order items.
1280 *
1281 * @param Order $this The current order object.
1282 * @param string $type Order item type. Default null.
1283 */
1284 do_action( 'storeengine/order/remove_order_items', $this, $type );
1285 if ( ! empty( $type ) ) {
1286 $this->delete_items( $type );
1287
1288 $group = $this->type_to_group( $type );
1289
1290 if ( $group ) {
1291 unset( $this->items[ $group ] );
1292 }
1293 } else {
1294 $this->delete_items();
1295 $this->items = [];
1296 }
1297 /**
1298 * Trigger action after removing all order line items.
1299 *
1300 * @param Order $this The current order object.
1301 * @param string $type Order item type. Default null.
1302 */
1303 do_action( 'storeengine/order/removed_order_items', $this, $type );
1304 }
1305
1306 /**
1307 * Remove all line items (products, coupons, shipping, taxes) from the order.
1308 *
1309 * @param ?string $type Order item type. Default null.
1310 */
1311 public function delete_items( ?string $type = null ) {
1312 global $wpdb;
1313
1314 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery -- deleting items, no need caching.
1315 if ( ! empty( $type ) ) {
1316 $wpdb->query( $wpdb->prepare( "DELETE itemmeta FROM {$wpdb->prefix}storeengine_order_item_meta as itemmeta INNER JOIN {$wpdb->prefix}storeengine_order_items as items WHERE itemmeta.order_item_id = items.order_item_id AND items.order_id = %d AND items.order_item_type = %s", $this->get_id(), $type ) );
1317 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}storeengine_order_items WHERE order_id = %d AND order_item_type = %s", $this->get_id(), $type ) );
1318 } else {
1319 $wpdb->query( $wpdb->prepare( "DELETE itemmeta FROM {$wpdb->prefix}storeengine_order_item_meta as itemmeta INNER JOIN {$wpdb->prefix}storeengine_order_items as items WHERE itemmeta.order_item_id = items.order_item_id and items.order_id = %d", $this->get_id() ) );
1320 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}storeengine_order_items WHERE order_id = %d", $this->get_id() ) );
1321 }
1322 // phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery -- deleting items, no need caching.
1323
1324 $this->clear_caches();
1325 }
1326
1327 /**
1328 * Convert a type to a types group.
1329 *
1330 * @param string $type type to lookup.
1331 *
1332 * @return string
1333 */
1334 protected function type_to_group( string $type ): ?string {
1335 $type_to_group = apply_filters( 'storeengine/order_type_to_group', $this->item_types_to_group );
1336
1337 return $type_to_group[ $type ] ?? '';
1338 }
1339
1340 /**
1341 * Return an array of items/products within this order.
1342 *
1343 * @param string|string[] $types Types of line items to get (array or string).
1344 * @param bool $force Force read from db.
1345 *
1346 * @return AbstractOrderItem[]
1347 */
1348 public function get_items( $types = 'line_item', bool $force = false ): array {
1349 $items = [];
1350 $types = array_filter( (array) $types );
1351
1352
1353 foreach ( $types as $type ) {
1354 $group = $this->type_to_group( $type );
1355
1356 if ( $group ) {
1357 if ( $force ) {
1358 $this->items[ $group ] = null;
1359 unset( $this->items[ $group ] );
1360 }
1361
1362 if ( ! isset( $this->items[ $group ] ) ) {
1363 $this->items[ $group ] = array_filter( $this->read_items( $type ) );
1364 }
1365
1366 // Don't use array_merge here because keys are numeric.
1367 $items = $items + $this->items[ $group ];
1368 }
1369 }
1370
1371 return apply_filters( 'storeengine/order_get_items', $items, $this, $types );
1372 }
1373
1374 /**
1375 * @return OrderItemProduct[]
1376 */
1377 public function get_line_product_items(): array {
1378 return $this->get_items( 'line_item' );
1379 }
1380
1381 /**
1382 * @return OrderItemTax[]
1383 */
1384 public function get_line_tax_items(): array {
1385 return $this->get_items( 'tax' );
1386 }
1387
1388 /**
1389 * @return OrderItemShipping[]
1390 */
1391 public function get_line_shipping_items(): array {
1392 return $this->get_items( 'shipping' );
1393 }
1394
1395 /**
1396 * @return OrderItemFee[]
1397 */
1398 public function get_line_fee_items(): array {
1399 return $this->get_items( 'fee' );
1400 }
1401
1402 /**
1403 * @return OrderItemCoupon[]
1404 */
1405 public function get_line_coupon_items(): array {
1406 return $this->get_items( 'coupon' );
1407 }
1408
1409 /**
1410 * Return array of values for calculations.
1411 *
1412 * @param string $field Field name to return.
1413 *
1414 * @return array Array of values.
1415 */
1416 protected function get_values_for_total( string $field ): array {
1417 return array_map(
1418 function ( $item ) use ( $field ) {
1419 $getter = 'get_' . $field;
1420 if ( method_exists( $item, $getter ) ) {
1421 return Formatting::add_number_precision( $item->{$getter}(), false );
1422 }
1423
1424 return 0;
1425 },
1426 array_values( $this->get_items() )
1427 );
1428 }
1429
1430 /**
1431 * Return an array of coupons within this order.
1432 *
1433 * @return ?OrderItemCoupon[]
1434 */
1435 public function get_coupons(): array {
1436 return $this->get_line_coupon_items();
1437 }
1438
1439 /**
1440 * Return an array of fees within this order.
1441 *
1442 * @return ?OrderItemFee[]
1443 */
1444 public function get_fees(): array {
1445 return $this->get_line_fee_items();
1446 }
1447
1448 /**
1449 * Return an array of taxes within this order.
1450 *
1451 * @return OrderItemTax[]
1452 */
1453 public function get_taxes(): array {
1454 return $this->get_line_tax_items();
1455 }
1456
1457 /**
1458 * Return an array of shipping costs within this order.
1459 *
1460 * @return OrderItemShipping[]
1461 */
1462 public function get_shipping_methods(): array {
1463 return $this->get_line_shipping_items();
1464 }
1465
1466 /**
1467 * Gets formatted shipping method title.
1468 *
1469 * @return string
1470 */
1471 public function get_shipping_method(): ?string {
1472 $names = [];
1473 foreach ( $this->get_shipping_methods() as $shipping_method ) {
1474 $names[] = $shipping_method->get_name();
1475 }
1476
1477 return apply_filters( 'storeengine/order/shipping_method', implode( ', ', $names ), $this );
1478 }
1479
1480 /**
1481 * Get used coupon codes only.
1482 *
1483 * @return array
1484 */
1485 public function get_coupon_codes(): array {
1486 $coupon_codes = [];
1487 $coupons = $this->get_line_coupon_items();
1488
1489 if ( $coupons ) {
1490 foreach ( $coupons as $coupon ) {
1491 $coupon_codes[] = $coupon->get_code();
1492 }
1493 }
1494
1495 return $coupon_codes;
1496 }
1497
1498 /**
1499 * Gets the count of order items of a certain type.
1500 *
1501 * @param string $item_type Item type to lookup.
1502 *
1503 * @return int|string
1504 */
1505 public function get_item_count( string $item_type = '' ) {
1506 $items = $this->get_items( empty( $item_type ) ? 'line_item' : $item_type );
1507 $count = 0;
1508
1509 foreach ( $items as $item ) {
1510 $count += $item->get_quantity();
1511 }
1512
1513 return apply_filters( 'storeengine/get_item_count', $count, $item_type, $this );
1514 }
1515
1516 /**
1517 * Get an order item object, based on its type.
1518 *
1519 * @param int|string $item_id ID of item to get.
1520 * @param bool $load_from_db Prior to 3.2 this item was loaded directly from the order factory, not this object. This param is here for backwards compatibility with that. If false, uses the local items variable instead.
1521 *
1522 * @return AbstractOrderItem|OrderItemProduct|OrderItemCoupon|OrderItemShipping|OrderItemTax|OrderItemFee|false
1523 */
1524 public function get_item( int $item_id, bool $load_from_db = true ) {
1525 if ( $load_from_db ) {
1526 return self::get_order_item( $item_id );
1527 }
1528
1529 // Search for item id.
1530 if ( $this->items ) {
1531 foreach ( $this->items as $items ) {
1532 if ( isset( $items[ $item_id ] ) ) {
1533 return $items[ $item_id ];
1534 }
1535 }
1536 }
1537
1538 // Load all items of type and cache.
1539 $type = AbstractOrderItem::get_order_item_type( $item_id );
1540
1541 if ( ! $type ) {
1542 return false;
1543 }
1544
1545 $items = $this->get_items( $type );
1546
1547 return ! empty( $items[ $item_id ] ) ? $items[ $item_id ] : false;
1548 }
1549
1550 /**
1551 * Get key for where a certain item type is stored in _items.
1552 *
1553 * @param string|AbstractOrderItem $item object Order item (product, shipping, fee, coupon, tax).
1554 *
1555 * @return string
1556 */
1557 protected function get_items_key( $item ): ?string {
1558 if ( is_a( $item, OrderItemProduct::class ) ) {
1559 return 'line_items';
1560 } elseif ( is_a( $item, OrderItemFee::class ) ) {
1561 return 'fee_lines';
1562 } elseif ( is_a( $item, OrderItemShipping::class ) ) {
1563 return 'shipping_lines';
1564 } elseif ( is_a( $item, OrderItemTax::class ) ) {
1565 return 'tax_lines';
1566 } elseif ( is_a( $item, OrderItemCoupon::class ) ) {
1567 return 'coupon_lines';
1568 }
1569
1570 return apply_filters( 'storeengine/get_items_key', '', $item );
1571 }
1572
1573 /**
1574 * Remove item from the order.
1575 *
1576 * @param int|string $item_id Item ID to delete.
1577 *
1578 * @return AbstractOrderItem|OrderItemProduct|OrderItemCoupon|OrderItemShipping|OrderItemTax|OrderItemFee|false
1579 */
1580 public function remove_item( $item_id ) {
1581 $item = $this->get_item( absint( $item_id ), false );
1582 $items_key = $item ? $this->get_items_key( $item ) : false;
1583
1584
1585 if ( ! $items_key ) {
1586 return false;
1587 }
1588
1589 // Unset and remove later.
1590 $this->items_to_delete[] = $item;
1591 unset( $this->items[ $items_key ][ $item->get_id() ] );
1592
1593 return $item;
1594 }
1595
1596 /**
1597 * Adds an order item to this order. The order item will not persist until save.
1598 *
1599 * @param AbstractOrderItem $item Order item object (product, shipping, fee, coupon, tax).
1600 *
1601 * @return false|void
1602 */
1603 public function add_item( AbstractOrderItem $item ) {
1604 $items_key = $this->get_items_key( $item );
1605
1606 if ( ! $items_key ) {
1607 return false;
1608 }
1609
1610 // Make sure existing items are loaded so we can append this new one.
1611 if ( ! isset( $this->items[ $items_key ] ) ) {
1612 $this->items[ $items_key ] = $this->get_items( $item->get_type() );
1613 }
1614
1615 // Set parent.
1616 $item->set_order_id( $this->get_id() );
1617
1618 // Append new row with generated temporary ID.
1619 $item_id = $item->get_id();
1620
1621 if ( $item_id ) {
1622 $this->items[ $items_key ][ $item_id ] = $item;
1623 } else {
1624 $this->items[ $items_key ][ 'new:' . $items_key . count( $this->items[ $items_key ] ) ] = $item;
1625 }
1626 }
1627
1628 /**
1629 * Check and records coupon usage tentatively so that counts validation is correct. Display an error if coupon usage limit has been reached.
1630 *
1631 * If you are using this method, make sure to `release_held_coupons` in case an Exception is thrown.
1632 *
1633 * @param string $billing_email Billing email of order.
1634 *
1635 * @throws Exception When not able to apply coupon.
1636 */
1637 public function hold_applied_coupons( string $billing_email ) {
1638 $held_keys = [];
1639 $held_keys_for_user = [];
1640 $error = null;
1641
1642 try {
1643 foreach ( Helper::cart()->get_coupons() as $coupon ) {
1644 // Hold coupon for when global coupon usage limit is present.
1645 if ( 0 < $coupon->get_usage_limit() ) {
1646 $held_key = $this->hold_coupon( $coupon );
1647 if ( $held_key ) {
1648 $held_keys[ $coupon->get_id() ] = $held_key;
1649 }
1650 }
1651
1652 // Hold coupon for when usage limit per customer is enabled.
1653 if ( 0 < $coupon->get_usage_limit_per_user() ) {
1654 $user_alias = '';
1655 if ( ! isset( $user_ids_and_emails ) ) {
1656 $user_alias = get_current_user_id() ? wp_get_current_user()->ID : sanitize_email( $billing_email );
1657 $user_ids_and_emails = $this->get_billing_and_current_user_aliases( $billing_email );
1658 }
1659
1660 $held_key_for_user = $this->hold_coupon_for_users( $coupon, $user_ids_and_emails, $user_alias );
1661
1662 if ( $held_key_for_user ) {
1663 $held_keys_for_user[ $coupon->get_id() ] = $held_key_for_user;
1664 }
1665 }
1666 }
1667 } catch ( Exception $e ) {
1668 $error = $e;
1669 } finally {
1670 // Even in case of error, we will save keys for whatever coupons that were held so our data remains accurate.
1671 // We save them in bulk instead of one by one for performance reasons.
1672 if ( 0 < count( $held_keys_for_user ) || 0 < count( $held_keys ) ) {
1673 $this->set_coupon_held_keys( $held_keys, $held_keys_for_user );
1674 }
1675 if ( $error instanceof Exception ) {
1676 throw $error;
1677 }
1678 }
1679 }
1680
1681 /**
1682 * Add/Update list of meta keys that are currently being used by this order to hold a coupon.
1683 * This is used to figure out what all meta entries we should delete when order is cancelled/completed.
1684 *
1685 * @param array $held_keys Array of coupon_code => meta_key.
1686 * @param array $held_keys_for_user Array of coupon_code => meta_key for held coupon for user.
1687 *
1688 * @return void
1689 */
1690 public function set_coupon_held_keys( $held_keys, $held_keys_for_user ) {
1691 if ( is_array( $held_keys ) && 0 < count( $held_keys ) ) {
1692 $this->update_meta_data( '_coupon_held_keys', $held_keys );
1693 }
1694 if ( is_array( $held_keys_for_user ) && 0 < count( $held_keys_for_user ) ) {
1695 $this->update_meta_data( '_coupon_held_keys_for_users', $held_keys_for_user );
1696 }
1697 }
1698
1699 /**
1700 * Return array of coupon_code => meta_key for coupon which have usage limit and have tentative keys.
1701 * Pass $coupon_id if key for only one of the coupon is needed.
1702 *
1703 * @param int|string $coupon_id If passed, will return held key for that coupon.
1704 *
1705 * @return array|string Key value pair for coupon code and meta key name. If $coupon_id is passed, returns meta_key for only that coupon.
1706 */
1707 public function get_coupon_held_keys( $coupon_id = null ) {
1708 $held_keys = $this->get_meta( '_coupon_held_keys' );
1709 if ( $coupon_id ) {
1710 return $held_keys[ $coupon_id ] ?? null;
1711 }
1712
1713 return $held_keys;
1714 }
1715
1716 /**
1717 * Return array of coupon_code => meta_key for coupon which have usage limit per customer and have tentative keys.
1718 *
1719 * @param int|string $coupon_id If passed, will return held key for that coupon.
1720 *
1721 * @return mixed
1722 */
1723 public function get_coupon_held_keys_for_users( $coupon_id = null ) {
1724 $held_keys_for_user = $this->get_meta( '_coupon_held_keys_for_users' );
1725 if ( $coupon_id ) {
1726 return $held_keys_for_user[ $coupon_id ] ?? null;
1727 }
1728
1729 return $held_keys_for_user;
1730 }
1731
1732 /**
1733 * Release all coupons held by this order.
1734 *
1735 * @param bool $save Whether to delete keys from DB right away. Could be useful to pass `false` if you are building a bulk request.
1736 */
1737 public function release_held_coupons( $save = true ) {
1738 $coupon_held_keys = $this->get_coupon_held_keys();
1739 if ( is_array( $coupon_held_keys ) ) {
1740 foreach ( $coupon_held_keys as $coupon_id => $meta_key ) {
1741 delete_post_meta( $coupon_id, $meta_key );
1742 }
1743 }
1744 $this->delete_meta_data( '_coupon_held_keys' );
1745
1746 $coupon_held_keys_for_users = $this->get_coupon_held_keys_for_users();
1747 if ( is_array( $coupon_held_keys_for_users ) ) {
1748 foreach ( $coupon_held_keys_for_users as $coupon_id => $meta_key ) {
1749 delete_post_meta( $coupon_id, $meta_key );
1750 }
1751 }
1752 $this->delete_meta_data( '_coupon_held_keys_for_users' );
1753
1754 if ( $save ) {
1755 $this->save_meta_data();
1756 }
1757 }
1758
1759 /**
1760 * Hold coupon if a global usage limit is defined.
1761 *
1762 * @param Coupon $coupon Coupon object.
1763 *
1764 * @return string Meta key which indicates held coupon.
1765 * @throws Exception When can't be held.
1766 */
1767 private function hold_coupon( Coupon $coupon ): ?string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
1768 return '';
1769 }
1770
1771 /**
1772 * Hold coupon if usage limit per customer is defined.
1773 *
1774 * @param Coupon $coupon Coupon object.
1775 * @param array $user_ids_and_emails Array of user Id and emails to check for usage limit.
1776 * @param string $user_alias User ID or email to use to record current usage.
1777 *
1778 * @return string Meta key which indicates held coupon.
1779 * @throws Exception When coupon can't be held.
1780 */
1781 private function hold_coupon_for_users( Coupon $coupon, array $user_ids_and_emails, string $user_alias ): ?string {
1782 return '';
1783 }
1784
1785 /**
1786 * Helper method to get all aliases for current user and provide billing email.
1787 *
1788 * @param string $billing_email Billing email provided in form.
1789 *
1790 * @return array Array of all aliases.
1791 */
1792 private function get_billing_and_current_user_aliases( string $billing_email ): array {
1793 $emails = [ $billing_email ];
1794 if ( get_current_user_id() ) {
1795 $emails[] = wp_get_current_user()->user_email;
1796 }
1797
1798 $emails = array_unique( array_map( fn( $email ) => strtolower( sanitize_email( $email ) ), $emails ) );
1799 $user_ids = Helper::get_user_ids_for_billing_email( $emails );
1800
1801 return array_merge( $user_ids, $emails );
1802 }
1803
1804 /**
1805 * Apply a coupon to the order and recalculate totals.
1806 *
1807 * @param string|Coupon $raw_coupon Coupon code or object.
1808 *
1809 * @return true|WP_Error True if applied, error if not.
1810 */
1811 public function apply_coupon( $raw_coupon ) {
1812 if ( is_a( $raw_coupon, Coupon::class ) ) {
1813 $coupon = $raw_coupon;
1814 } elseif ( is_string( $raw_coupon ) ) {
1815 $code = Formatting::format_coupon_code( $raw_coupon );
1816 $coupon = new Coupon( $code );
1817
1818 if ( strtolower( $coupon->get_code() ) !== $code ) {
1819 return new WP_Error( 'invalid_coupon', esc_html__( 'Invalid coupon code', 'storeengine' ) );
1820 }
1821 } else {
1822 return new WP_Error( 'invalid_coupon', esc_html__( 'Invalid coupon', 'storeengine' ) );
1823 }
1824
1825 // Check to make sure coupon is not already applied.
1826 $applied_coupons = $this->get_line_coupon_items();
1827 foreach ( $applied_coupons as $applied_coupon ) {
1828 if ( strtolower( $applied_coupon->get_code() ) === strtolower( $coupon->get_code() ) ) {
1829 return new WP_Error( 'duplicate_coupon', esc_html__( 'Coupon code already applied!', 'storeengine' ) );
1830 }
1831 }
1832
1833 $discounts = new Discounts( $this );
1834 $applied = $discounts->apply_coupon( $coupon );
1835
1836 if ( is_wp_error( $applied ) ) {
1837 return $applied;
1838 }
1839
1840 /**
1841 * Check specific for guest checkouts here as well since the cart handles that separately in check_customer_coupons.
1842 *
1843 * @todo implement get_usage_by_email & get_usage_limit_per_user and handle
1844 * validation for "Coupon usage limit has been reached." error.
1845 */
1846
1847 /**
1848 * Action to signal that a coupon has been applied to an order.
1849 *
1850 * @param Coupon $coupon The applied coupon object.
1851 * @param Order $this The current order object.
1852 */
1853 do_action( 'storeengine/order/applied_coupon', $coupon, $this );
1854
1855 $this->set_coupon_discount_amounts( $discounts );
1856 $this->save();
1857
1858 // Recalculate totals and taxes.
1859 $this->recalculate_coupons();
1860
1861 // @TODO update usage count. Record usage so counts and validation is correct.
1862
1863 return true;
1864 }
1865
1866 /**
1867 * Remove a coupon from the order and recalculate totals.
1868 *
1869 * Coupons affect line item totals, but there is no relationship between
1870 * coupon and line total, so to remove a coupon we need to work from the
1871 * line subtotal (price before discount) and re-apply all coupons in this
1872 * order.
1873 *
1874 * Manual discounts are not affected; those are separate and do not affect
1875 * stored line totals.
1876 *
1877 * @param ?string $code Coupon code.
1878 *
1879 * @return bool TRUE if coupon was removed, FALSE otherwise.
1880 */
1881 public function remove_coupon( ?string $code ): bool {
1882 $coupons = $this->get_line_coupon_items();
1883 $code = Formatting::format_coupon_code( $code );
1884
1885 // Remove the coupon line.
1886 foreach ( $coupons as $item_id => $coupon ) {
1887 if ( $coupon->get_code() === $code ) {
1888 $this->remove_item( $item_id );
1889 // @TODO decrease coupon usage count if increased.
1890 $this->recalculate_coupons();
1891
1892 return true;
1893 }
1894 }
1895
1896 return false;
1897 }
1898
1899 /**
1900 * Apply all coupons in this order again to all line items.
1901 */
1902 public function recalculate_coupons() {
1903 // Reset line item totals.
1904 foreach ( $this->get_line_product_items() as $item ) {
1905 $item->set_total( $item->get_subtotal() );
1906 $item->set_total_tax( $item->get_subtotal_tax() );
1907 }
1908
1909 $discounts = new Discounts( $this );
1910
1911 foreach ( $this->get_line_coupon_items() as $coupon_item ) {
1912 $coupon_code = $coupon_item->get_code();
1913 $coupon_id = Coupon::get_by_code( $coupon_code );
1914
1915 // If we have a coupon ID (looked up by code) we can simply load the new coupon object using the ID.
1916 if ( $coupon_id ) {
1917 $coupon_object = new Coupon( $coupon_id );
1918 } else {
1919 // If we do not have a coupon ID (was it virtual? has it been deleted?) we must create a temporary coupon using what data we have stored during checkout.
1920 $coupon_object = $this->get_temporary_coupon( $coupon_item );
1921
1922 // If there is no coupon amount (maybe dynamic?), set it to the given **discount** amount so the coupon's same value is applied.
1923 if ( ! $coupon_object->get_amount() ) {
1924
1925 // If the order originally had prices including tax, remove the discount + discount tax.
1926 if ( $this->get_prices_include_tax() ) {
1927 $coupon_object->settings['coupon_amount'] = (float) $coupon_item->get_discount() + (float) $coupon_item->get_discount_tax();
1928 } else {
1929 $coupon_object->settings['coupon_amount'] = (float) $coupon_item->get_discount();
1930 }
1931
1932 $coupon_object->settings['coupon_type'] = 'fixedAmount';
1933 }
1934 }
1935
1936 /**
1937 * Allow developers to filter this coupon before it gets re-applied to the order.
1938 */
1939 $coupon_object = apply_filters( 'storeengine/order_recalculate_coupons_coupon_object', $coupon_object, $coupon_code, $coupon_item, $this );
1940
1941 if ( $coupon_object ) {
1942 $discounts->apply_coupon( $coupon_object, false );
1943 }
1944 }
1945
1946 $this->set_coupon_discount_amounts( $discounts );
1947 $this->set_item_discount_amounts( $discounts );
1948
1949 // Recalculate totals and taxes.
1950 $this->calculate_totals( true );
1951 }
1952
1953 /**
1954 * Get a coupon object populated from order line item metadata, to be used when reapplying coupons
1955 * if the original coupon no longer exists.
1956 *
1957 * @param OrderItemCoupon $coupon_item The order item corresponding to the coupon to reapply.
1958 *
1959 * @returns Coupon Coupon object populated from order line item metadata, or empty if no such metadata exists (should never happen).
1960 */
1961 private function get_temporary_coupon( OrderItemCoupon $coupon_item ): Coupon {
1962 $coupon_object = new Coupon();
1963
1964 // @TODO check coupon info & set coupon info.
1965
1966 $coupon_settings = $coupon_item->get_meta( 'coupon_settings' );
1967 if ( $coupon_settings ) {
1968 $coupon_object->settings = $coupon_settings;
1969 }
1970
1971 return $coupon_object;
1972 }
1973
1974 /**
1975 * After applying coupons via the discounts engine, update line items.
1976 *
1977 * @param Discounts $discounts Discounts class.
1978 */
1979 protected function set_item_discount_amounts( Discounts $discounts ) {
1980 $item_discounts = $discounts->get_discounts_by_item();
1981 $tax_location = $this->get_tax_location();
1982 $tax_location = array(
1983 $tax_location['country'],
1984 $tax_location['state'],
1985 $tax_location['postcode'],
1986 $tax_location['city'],
1987 );
1988
1989 if ( $item_discounts ) {
1990 foreach ( $item_discounts as $item_id => $amount ) {
1991 $item = $this->get_item( $item_id, false );
1992
1993 // If the prices include tax, discounts should be taken off the tax inclusive prices like in the cart.
1994 if ( $this->get_prices_include_tax() && TaxUtil::is_tax_enabled() && ProductTaxStatus::TAXABLE === $item->get_tax_status() ) {
1995 $taxes = Tax::calc_tax( $amount, $this->get_tax_rates( $item->get_tax_class(), $tax_location ), true );
1996
1997 // Use unrounded taxes so totals will be re-calculated accurately, like in cart.
1998 $amount = $amount - array_sum( $taxes );
1999 }
2000
2001 $item->set_total( max( 0, (float) $item->get_total() - $amount ) );
2002 }
2003 }
2004 }
2005
2006 /**
2007 * After applying coupons via the discounts engine, update or create coupon items.
2008 *
2009 * @param Discounts $discounts Discounts class.
2010 *
2011 * @throws StoreEngineException
2012 */
2013 protected function set_coupon_discount_amounts( Discounts $discounts ) {
2014 $coupons = $this->get_line_coupon_items();
2015 $coupon_code_to_id = Helper::list_pluck( $coupons, 'get_id', 'get_code' );
2016 $all_discounts = $discounts->get_discounts();
2017 $coupon_discounts = $discounts->get_discounts_by_coupon();
2018 $tax_location = $this->get_tax_location();
2019 $tax_location = [
2020 $tax_location['country'],
2021 $tax_location['state'],
2022 $tax_location['postcode'],
2023 $tax_location['city'],
2024 ];
2025
2026 if ( $coupon_discounts ) {
2027 foreach ( $coupon_discounts as $coupon_code => $amount ) {
2028 $item_id = $coupon_code_to_id[ $coupon_code ] ?? 0;
2029
2030
2031 if ( ! $item_id ) {
2032 $coupon_item = new OrderItemCoupon();
2033 $coupon_item->set_code( $coupon_code );
2034
2035 // Add coupon data.
2036 $coupon_id = Coupon::get_by_code( $coupon_code );
2037 $coupon = new Coupon( (string) $coupon_id );
2038
2039 // @TODO check coupon_info and get_short_info
2040 $coupon_item->add_meta_data( 'coupon_settings', $coupon->get_settings() );
2041 } else {
2042 $coupon_item = $this->get_item( $item_id, false );
2043 }
2044
2045 $discount_tax = 0;
2046
2047 // Work out how much tax has been removed as a result of the discount from this coupon.
2048 foreach ( $all_discounts[ $coupon_code ] as $item_id => $item_discount_amount ) {
2049 $item = $this->get_item( $item_id, false );
2050
2051 if ( ! $item || ProductTaxStatus::TAXABLE !== $item->get_tax_status() || ! TaxUtil::is_tax_enabled() ) {
2052 continue;
2053 }
2054
2055 $taxes = array_sum( Tax::calc_tax( $item_discount_amount, $this->get_tax_rates( $item->get_tax_class(), $tax_location ), $this->get_prices_include_tax() ) );
2056 if ( ! TaxUtil::tax_round_at_subtotal() ) {
2057 $taxes = Formatting::round_tax_total( $taxes );
2058 }
2059
2060 $discount_tax += $taxes;
2061
2062 if ( $this->get_prices_include_tax() ) {
2063 $amount = $amount - $taxes;
2064 }
2065 }
2066
2067 $coupon_item->set_discount( $amount );
2068 $coupon_item->set_discount_tax( $discount_tax );
2069
2070 $this->add_item( $coupon_item );
2071 }
2072 }
2073 }
2074
2075 /**
2076 * Add a product line item to the order. This is the only line item type with
2077 * its own method because it saves looking up order amounts (costs are added up for you).
2078 *
2079 * This also adds the `auto_complete_digital_order` meta but doesn't execute save method.
2080 *
2081 * @param int|Price $price
2082 * @param int $quantity
2083 * @param array $args Args for the added product (can override price value).
2084 *
2085 * @return int return newly created order-item id.
2086 * @throws StoreEngineException
2087 */
2088 public function add_product( $price, int $quantity = 1, array $args = [] ): int {
2089 if ( is_numeric( $price ) ) {
2090 $price = new Price( $price );
2091 }
2092
2093 $variation_id = $args['variation_id'] ?? 0;
2094 $variation = false;
2095 $_price = $price->get_price();
2096
2097 if ( 0 < $variation_id ) {
2098 $variation = Helper::get_product_variation( $variation_id );
2099
2100 if ( ! $variation ) {
2101 return - 1;
2102 }
2103
2104 $_price = $_price + (float) $variation->get_price();
2105 }
2106
2107 // Calculate item total.
2108 $total = Formatting::get_price_excluding_tax(
2109 $_price,
2110 $price->get_id(),
2111 $price->get_product_id(),
2112 [
2113 'qty' => $quantity,
2114 'order' => $args['order'] ?? $this,
2115 ]
2116 );
2117
2118 // Parse.
2119 $args = wp_parse_args( $args, [
2120 // Product
2121 'name' => $args['name'] ?? $price->get_product_title(),
2122 'product_id' => $args['product_id'] ?? $price->get_product_id(),
2123 'variation_id' => $args['variation_id'] ?? 0,
2124 'variation' => $args['variation'] ?? [],
2125 // Type
2126 'product_type' => $price->get_product_type() ?? '',
2127 'shipping_type' => $price->get_shipping_type() ?? '',
2128 'digital_auto_complete' => $price->get_digital_auto_complete(),
2129 'price_type' => $price->get_price_type(),
2130 // Price
2131 'price_id' => $price->get_id(),
2132 'price_name' => $price->get_name(),
2133 'price' => $_price,
2134 // cart & tax
2135 'quantity' => $quantity,
2136 'tax_class' => $args['tax_class'] ?? '',
2137 'subtotal' => $total,
2138 'total' => $total,
2139 ] );
2140
2141 // Unset unknown prop for order-item
2142 $add_fee = $args['fee'] ?? false;
2143 unset( $args['fee'] );
2144
2145 if ( array_key_exists( 'order', $args ) ) {
2146 unset( $args['order'] );
2147 }
2148
2149 $item = new OrderItemProduct();
2150
2151 $item->set_props( $args );
2152
2153 $item->add_meta_data( '_price_settings', $price->get_settings(), true );
2154
2155 foreach ( $price->get_settings() as $field => $value ) {
2156 if ( method_exists( $item, "set_{$field}" ) ) {
2157 $item->{"set_{$field}"}( $value );
2158 }
2159 }
2160
2161 $item->set_backorder_meta();
2162
2163 if ( $variation ) {
2164 $item->add_meta_data( '_variation_price', (float) $variation->get_price(), true );
2165 foreach ( $variation->get_attributes() as $attribute ) {
2166 $item->add_meta_data( $attribute->taxonomy, $attribute->slug, true );
2167 }
2168 }
2169
2170 if ( 'bundled' === $item->get_product_type() && $price->get_product()->get_bundles() ) {
2171 $bundles = [];
2172
2173 foreach ( $price->get_product()->get_bundles() as $bundle ) {
2174 try {
2175 $bundle_price = new Price( $bundle['price_id'] );
2176 $bundle_product = $bundle_price->get_product();
2177 if ( ! $bundle_product ) {
2178 continue;
2179 }
2180
2181 // Don't include the link (product can be in trash or removed).
2182 $bundles[] = array_merge( $bundle, [
2183 'product_name' => $bundle_product->get_name(),
2184 'quantity' => 1,
2185 'price' => $bundle_price->get_price(),
2186 'price_name' => $bundle_price->get_name(),
2187 ] );
2188 } catch ( Exception $e ) {
2189 Helper::log_error( $e );
2190 // No-Op.
2191 }
2192 }
2193
2194 $item->add_meta_data( '_bundles', $bundles, true );
2195 }
2196
2197 /**
2198 * @TODO add do action similar to checkout-add-product so addon can hook
2199 * @see CheckoutService::add_product()
2200 */
2201
2202 $item->set_backorder_meta();
2203
2204 /**
2205 * @param OrderItemProduct $item
2206 * @param array $args
2207 * @param Order $order
2208 */
2209 do_action( 'storeengine/' . $this->get_object_type() . '/create_order_line_item', $item, $args, $this );
2210
2211 $item->set_order_id( $this->get_id() );
2212 $item->save();
2213 $this->add_item( $item );
2214
2215 if ( $add_fee && $price->has_setup_fee() && $price->get_setup_fee_price() ) {
2216 $this->add_fee( $price->get_setup_fee_name(), $price->get_setup_fee_price() );
2217 }
2218
2219 delete_transient( 'storeengine/order_' . $this->get_id() . '_needs_processing' );
2220
2221 /**
2222 * Set auto-complete from outside if this order has multiple item.
2223 * @see \StoreEngine\Classes\CheckoutService::add_product()
2224 */
2225
2226 return $item->get_id();
2227 }
2228
2229 /**
2230 * @param string $name
2231 * @param $amount
2232 * @param string $tax_class
2233 *
2234 * @return int|WP_Error
2235 */
2236 public function add_fee( string $name, $amount, string $tax_class = '' ) {
2237 $fees = $this->get_fees();
2238
2239 if ( $fees ) {
2240 foreach ( $fees as $fee ) {
2241 $exisitng_hash = strtolower( trim( $fee->get_name( 'edit' ) . $fee->get_amount( 'edit' ) ) );
2242 $new_hash = strtolower( trim( $name . $amount ) );
2243 if ( $exisitng_hash === $new_hash ) {
2244 return new WP_Error( 'duplicate-fee', esc_html__( 'Fee already applied.', 'storeengine' ) );
2245 }
2246 }
2247 }
2248
2249 $fee = [
2250 'order_id' => $this->get_id(),
2251 'name' => trim( $name ),
2252 'tax_class' => trim( $tax_class ),
2253 'amount' => $amount,
2254 'total' => $amount,
2255 ];
2256 $item = new OrderItemFee();
2257 $item->set_props( $fee );
2258
2259 $item->set_order_id( $this->get_id() );
2260 $item->save();
2261
2262 do_action( 'storeengine/' . $this->get_object_type() . '/create_order_fee_item', $item, $fee, $this );
2263
2264 $this->add_item( $item );
2265
2266 return $item->get_id();
2267 }
2268
2269 /*
2270 |--------------------------------------------------------------------------
2271 | Payment Token Handling
2272 |--------------------------------------------------------------------------
2273 |
2274 | Payment tokens are hashes used to take payments by certain gateways.
2275 |
2276 */
2277
2278 /**
2279 * Add a payment token to an order
2280 *
2281 * @param PaymentToken|null|string $token Payment token object.
2282 *
2283 * @return boolean|int The new token ID or false if it failed.
2284 */
2285 public function add_payment_token( $token ) {
2286 if ( empty( $token ) || ! ( $token instanceof PaymentToken ) ) {
2287 return false;
2288 }
2289
2290 $token_ids = $this->get_payment_tokens();
2291 $token_ids[] = $token->get_id();
2292
2293 $this->add_meta_data( '_payment_tokens', $token_ids, true );
2294
2295 $order_id = $this->get_id();
2296 $token_id = $token->get_id();
2297 /**
2298 * Fires after payment token added to order.
2299 *
2300 * @param int $order_id Order id.
2301 * @param int $token_id Token id.
2302 * @param PaymentToken $token Token object.
2303 * @param array $token_ids All token ids if current order.
2304 */
2305 do_action( 'storeengine/order/payment_token_added', $order_id, $token_id, $token, $token_ids );
2306
2307 return $token_id;
2308 }
2309
2310 /**
2311 * Returns a list of all payment tokens associated with the current order.
2312 *
2313 * @param string $context
2314 *
2315 * @return array An array of payment token objects
2316 */
2317 public function get_payment_tokens( string $context = 'view' ): array {
2318 return array_filter( (array) $this->get_meta( '_payment_tokens', true, $context ) );
2319 }
2320
2321 /*
2322 |--------------------------------------------------------------------------
2323 | Calculations.
2324 |--------------------------------------------------------------------------
2325 |
2326 | These methods calculate order totals and taxes based on the current data.
2327 |
2328 */
2329
2330 /**
2331 * Calculate shipping total.
2332 *
2333 * @return float
2334 */
2335 public function calculate_shipping() {
2336 $shipping_total = 0;
2337
2338 foreach ( $this->get_shipping_methods() as $shipping ) {
2339 $shipping_total += (float) $shipping->get_total();
2340 }
2341
2342 $this->set_shipping_total( $shipping_total );
2343 $this->save();
2344
2345 return $this->get_shipping_total();
2346 }
2347
2348 /**
2349 * Get all tax classes for items in the order.
2350 *
2351 * @return array
2352 */
2353 public function get_items_tax_classes() {
2354 $found_tax_classes = [];
2355 $valid_statuses = [ ProductTaxStatus::TAXABLE, ProductTaxStatus::SHIPPING ];
2356
2357 foreach ( $this->get_items() as $item ) {
2358 if ( is_callable( [
2359 $item,
2360 'get_tax_status'
2361 ] ) && in_array( $item->get_tax_status(), $valid_statuses, true ) ) {
2362 $found_tax_classes[] = $item->get_tax_class();
2363 }
2364 }
2365
2366 return array_unique( $found_tax_classes );
2367 }
2368
2369 /**
2370 * Get tax location for this order.
2371 *
2372 * @param array $args array Override the location.
2373 *
2374 * @return array
2375 */
2376 protected function get_tax_location( array $args = [] ) {
2377 $tax_based_on = TaxUtil::tax_based_on();
2378
2379 if ( 'shipping' === $tax_based_on && ! $this->get_shipping_country() ) {
2380 $tax_based_on = 'billing';
2381 }
2382
2383 $args = wp_parse_args(
2384 $args,
2385 [
2386 'country' => 'billing' === $tax_based_on ? $this->get_billing_country() : $this->get_shipping_country(),
2387 'state' => 'billing' === $tax_based_on ? $this->get_billing_state() : $this->get_shipping_state(),
2388 'postcode' => 'billing' === $tax_based_on ? $this->get_billing_postcode() : $this->get_shipping_postcode(),
2389 'city' => 'billing' === $tax_based_on ? $this->get_billing_city() : $this->get_shipping_city(),
2390 ]
2391 );
2392
2393 /**
2394 * Filters whether apply base tax for local pickup shipping method or not.
2395 *
2396 * @param boolean $apply_base_tax Apply_base_tax Whether apply base tax for local pickup. Default true.
2397 */
2398 $apply_base_tax = true === apply_filters( 'storeengine/apply_base_tax_for_local_pickup', true );
2399
2400 /**
2401 * Filters local pickup shipping methods.
2402 *
2403 * @param string[] $local_pickup_methods Local pickup shipping method IDs.
2404 */
2405 $local_pickup_methods = apply_filters( 'storeengine/local_pickup_methods', [ 'local_pickup' ] );
2406
2407 $shipping_method_ids = array_map( fn( $item ) => $item->get_method_id(), $this->get_shipping_methods() );
2408
2409 // Set shop base address as a tax location if order has local pickup shipping method.
2410 if ( $apply_base_tax && count( array_intersect( $shipping_method_ids, $local_pickup_methods ) ) > 0 ) {
2411 $tax_based_on = 'base';
2412 }
2413
2414 // Default to base.
2415 if ( 'base' === $tax_based_on || empty( $args['country'] ) ) {
2416 $args['country'] = Countries::init()->get_base_country();
2417 $args['state'] = Countries::init()->get_base_state();
2418 $args['postcode'] = Countries::init()->get_base_postcode();
2419 $args['city'] = Countries::init()->get_base_city();
2420 }
2421
2422 return apply_filters( 'storeengine/order_get_tax_location', $args, $this );
2423 }
2424
2425 /**
2426 * Public wrapper for exposing get_tax_location() method, enabling 3rd parties to get the tax location for an order.
2427 *
2428 * @param array $args array Override the location.
2429 *
2430 * @return array
2431 */
2432 public function get_taxable_location( array $args = [] ): array {
2433 return $this->get_tax_location( $args );
2434 }
2435
2436 /**
2437 * Get tax rates for an order. Use order's shipping or billing address, defaults to base location.
2438 *
2439 * @param string $tax_class Tax class to get rates for.
2440 * @param array $location_args Location to compute rates for. Should be in form: array( country, state, postcode, city).
2441 * @param ?Customer $customer Only used to maintain backward compatibility for filter `storeengine/matched_rates`.
2442 *
2443 * @return mixed|void Tax rates.
2444 */
2445 protected function get_tax_rates( string $tax_class, array $location_args = [], ?Customer $customer = null ) {
2446 $tax_location = $this->get_tax_location( $location_args );
2447 $tax_location = array(
2448 $tax_location['country'],
2449 $tax_location['state'],
2450 $tax_location['postcode'],
2451 $tax_location['city'],
2452 );
2453
2454 return Tax::get_rates_from_location( $tax_class, $tax_location, $customer );
2455 }
2456
2457 /**
2458 * Calculate taxes for all line items and shipping, and store the totals and tax rows.
2459 *
2460 * If by default the taxes are based on the shipping address and the current order doesn't
2461 * have any, it would use the billing address rather than using the Shopping base location.
2462 *
2463 * Will use the base country unless customer addresses are set.
2464 *
2465 * @param array $args Pass things like location.
2466 */
2467 public function calculate_taxes( array $args = [] ) {
2468 /**
2469 * Fires before tax calculation on order.
2470 *
2471 * @param array $args Pass things like location.
2472 * @param Order $this Order object.
2473 */
2474 do_action( 'storeengine/order/before_calculate_taxes', $args, $this );
2475
2476 $calculate_tax_for = $this->get_tax_location( $args );
2477 $shipping_tax_class = Helper::get_settings( 'shipping_tax_class', '' );
2478
2479 if ( 'inherit' === $shipping_tax_class ) {
2480 $found_classes = array_intersect( array_merge( array( '' ), Tax::get_tax_class_slugs() ), $this->get_items_tax_classes() );
2481 $shipping_tax_class = count( $found_classes ) ? current( $found_classes ) : false;
2482 }
2483
2484 $is_vat_exempt = apply_filters( 'storeengine/order_is_vat_exempt', 'yes' === $this->get_meta( 'is_vat_exempt' ), $this );
2485
2486 // Trigger tax recalculation for all items.
2487 foreach ( $this->get_items( [ 'line_item', 'fee' ] ) as $item ) {
2488 if ( ! $is_vat_exempt ) {
2489 $item->calculate_taxes( $calculate_tax_for );
2490 } else {
2491 $item->set_taxes( false );
2492 }
2493
2494 if ( $item->get_changes() ) {
2495 $item->save();
2496 }
2497 }
2498
2499 foreach ( $this->get_shipping_methods() as $item_id => $item ) {
2500 if ( false !== $shipping_tax_class && ! $is_vat_exempt ) {
2501 $item->calculate_taxes( array_merge( $calculate_tax_for, array( 'tax_class' => $shipping_tax_class ) ) );
2502 } else {
2503 $item->set_taxes( false );
2504 }
2505 if ( $item->get_changes() ) {
2506 $item->save();
2507 }
2508 }
2509
2510
2511 $this->update_taxes();
2512 }
2513
2514 /**
2515 * Calculate fees for all line items.
2516 *
2517 * @return float Fee total.
2518 */
2519 public function get_total_fees(): float {
2520 return array_reduce(
2521 $this->get_fees(),
2522 function ( $carry, $item ) {
2523 return $carry + (float) $item->get_total();
2524 },
2525 0.0
2526 );
2527 }
2528
2529 /**
2530 * Update tax lines for the order based on the line item taxes themselves.
2531 */
2532 public function update_taxes() {
2533 $cart_taxes = [];
2534 $shipping_taxes = [];
2535 $existing_taxes = $this->get_taxes();
2536 $saved_rate_ids = [];
2537
2538 foreach ( $this->get_items( [ 'line_item', 'fee' ] ) as $item_id => $item ) {
2539 $taxes = $item->get_taxes();
2540 foreach ( $taxes['total'] as $tax_rate_id => $tax ) {
2541 $tax_amount = (float) $this->round_line_tax( $tax, false );
2542
2543 $cart_taxes[ $tax_rate_id ] = isset( $cart_taxes[ $tax_rate_id ] ) ? (float) $cart_taxes[ $tax_rate_id ] + $tax_amount : $tax_amount;
2544 }
2545 }
2546
2547 foreach ( $this->get_shipping_methods() as $item_id => $item ) {
2548 $taxes = $item->get_taxes();
2549 foreach ( $taxes['total'] as $tax_rate_id => $tax ) {
2550 $tax_amount = (float) $tax;
2551
2552 if ( ! TaxUtil::tax_round_at_subtotal() ) {
2553 $tax_amount = Formatting::round_tax_total( $tax_amount );
2554 }
2555
2556 $shipping_taxes[ $tax_rate_id ] = isset( $shipping_taxes[ $tax_rate_id ] ) ? (float) $shipping_taxes[ $tax_rate_id ] + $tax_amount : $tax_amount;
2557 }
2558 }
2559
2560 foreach ( $existing_taxes as $tax ) {
2561 // Remove taxes which no longer exist for cart/shipping.
2562 if ( ( ! array_key_exists( $tax->get_rate_id(), $cart_taxes ) && ! array_key_exists( $tax->get_rate_id(), $shipping_taxes ) ) || in_array( $tax->get_rate_id(), $saved_rate_ids, true ) ) {
2563 $this->remove_item( $tax->get_id() );
2564 continue;
2565 }
2566 $saved_rate_ids[] = $tax->get_rate_id();
2567 $tax->set_rate( $tax->get_rate_id() );
2568 $tax->set_tax_total( isset( $cart_taxes[ $tax->get_rate_id() ] ) ? $cart_taxes[ $tax->get_rate_id() ] : 0 );
2569 $tax->set_label( Tax::get_rate_label( $tax->get_rate_id() ) );
2570 $tax->set_shipping_tax_total( ! empty( $shipping_taxes[ $tax->get_rate_id() ] ) ? $shipping_taxes[ $tax->get_rate_id() ] : 0 );
2571 $tax->save();
2572 }
2573
2574 $new_rate_ids = wp_parse_id_list( array_diff( array_keys( $cart_taxes + $shipping_taxes ), $saved_rate_ids ) );
2575
2576 // New taxes.
2577 foreach ( $new_rate_ids as $tax_rate_id ) {
2578 $item = new OrderItemTax();
2579 $item->set_rate( $tax_rate_id );
2580 $item->set_tax_total( $cart_taxes[ $tax_rate_id ] ?? 0 );
2581 $item->set_shipping_tax_total( ! empty( $shipping_taxes[ $tax_rate_id ] ) ? $shipping_taxes[ $tax_rate_id ] : 0 );
2582 $this->add_item( $item );
2583 }
2584
2585 $this->set_shipping_tax( array_sum( $shipping_taxes ) );
2586 $this->set_cart_tax( array_sum( $cart_taxes ) );
2587 $this->save();
2588 }
2589
2590 /**
2591 * Helper function.
2592 * If you add all items in this order in cart again, this would be the cart subtotal (assuming all other settings are same).
2593 *
2594 * @return float Cart subtotal.
2595 */
2596 protected function get_cart_subtotal_for_order(): float {
2597 return Formatting::remove_number_precision( $this->get_rounded_items_total( $this->get_values_for_total( 'subtotal' ) ) );
2598 }
2599
2600 /**
2601 * Helper function.
2602 * If you add all items in this order in cart again, this would be the cart total (assuming all other settings are same).
2603 *
2604 * @return float Cart total.
2605 */
2606 protected function get_cart_total_for_order(): float {
2607 return Formatting::remove_number_precision( $this->get_rounded_items_total( $this->get_values_for_total( 'total' ) ) );
2608 }
2609
2610 /**
2611 * Calculate totals by looking at the contents of the order. Stores the totals and returns the orders final total.
2612 *
2613 * @param bool $and_taxes Calc taxes if true.
2614 *
2615 * @return float calculated grand total.
2616 */
2617 public function calculate_totals( bool $and_taxes = true ) {
2618 /**
2619 * Fires before calculate totals on Order.
2620 *
2621 * @param bool $and_taxes Calc taxes if true.
2622 * @param Order $this Order object.
2623 */
2624 do_action( 'storeengine/order/before_calculate_totals', $and_taxes, $this );
2625
2626 $fees_total = 0;
2627 $shipping_total = 0;
2628 $cart_subtotal_tax = 0;
2629 $cart_total_tax = 0;
2630
2631 $cart_subtotal = $this->get_cart_subtotal_for_order();
2632 $cart_total = (float) $this->get_cart_total_for_order();
2633
2634 // Sum shipping costs.
2635 foreach ( $this->get_shipping_methods() as $shipping ) {
2636 $shipping_total += NumberUtil::round( $shipping->get_total(), Formatting::get_price_decimals() );
2637 }
2638
2639 $this->set_shipping_total( $shipping_total );
2640
2641 // Sum fee costs.
2642 foreach ( $this->get_fees() as $item ) {
2643 $fee_total = (float) $item->get_total();
2644
2645 if ( 0 > $fee_total ) {
2646 $max_discount = NumberUtil::round( $cart_total + $fees_total + $shipping_total, Formatting::get_price_decimals() ) * - 1;
2647
2648 if ( $fee_total < $max_discount && 0 > $max_discount ) {
2649 $item->set_total( $max_discount );
2650 }
2651 }
2652 $fees_total += (float) $item->get_total();
2653 }
2654
2655 // Calculate taxes for items, shipping, discounts. Note; this also triggers save().
2656 if ( $and_taxes ) {
2657 $this->calculate_taxes();
2658 }
2659
2660 // Sum taxes again so we can work out how much tax was discounted. This uses original values, not those possibly rounded to 2dp.
2661 foreach ( $this->get_items() as $item ) {
2662 $taxes = $item->get_taxes();
2663
2664 foreach ( $taxes['total'] as $tax ) {
2665 $cart_total_tax += (float) $tax;
2666 }
2667
2668 foreach ( $taxes['subtotal'] as $tax ) {
2669 $cart_subtotal_tax += (float) $tax;
2670 }
2671 }
2672
2673 $this->set_discount_total( NumberUtil::round( $cart_subtotal - $cart_total, Formatting::get_price_decimals() ) );
2674 $this->set_discount_tax( Formatting::round_tax_total( $cart_subtotal_tax - $cart_total_tax ) );
2675 $this->set_total( NumberUtil::round( $cart_total + $fees_total + (float) $this->get_shipping_total() + (float) $this->get_cart_tax() + (float) $this->get_shipping_tax(), Formatting::get_price_decimals() ) );
2676
2677 /**
2678 * Fires after calculate totals on Order.
2679 *
2680 * @param bool $and_taxes Calc taxes if true.
2681 * @param Order $this Order object.
2682 */
2683 do_action( 'storeengine/order/after_calculate_totals', $and_taxes, $this );
2684
2685 $this->save();
2686
2687 return $this->get_total();
2688 }
2689
2690 /**
2691 * Get item subtotal - this is the cost before discount.
2692 *
2693 * @param object $item Item to get total from.
2694 * @param bool $inc_tax (default: false).
2695 * @param bool $round (default: true).
2696 *
2697 * @return float
2698 */
2699 public function get_item_subtotal( object $item, bool $inc_tax = false, bool $round = true ): float {
2700 $subtotal = 0;
2701
2702 if ( is_callable( array( $item, 'get_subtotal' ) ) && $item->get_quantity() ) {
2703 if ( $inc_tax ) {
2704 $subtotal = ( (float) $item->get_subtotal() + (float) $item->get_subtotal_tax() ) / $item->get_quantity();
2705 } else {
2706 $subtotal = ( (float) $item->get_subtotal() ) / $item->get_quantity();
2707 }
2708
2709 $subtotal = $round ? NumberUtil::round( $subtotal, Formatting::get_price_decimals() ) : $subtotal;
2710 }
2711
2712 return apply_filters( 'storeengine/order_amount_item_subtotal', $subtotal, $this, $item, $inc_tax, $round );
2713 }
2714
2715 /**
2716 * Get line subtotal - this is the cost before discount.
2717 *
2718 * @param AbstractOrderItem $item Item to get total from.
2719 * @param bool $inc_tax (default: false).
2720 * @param bool $round (default: true).
2721 *
2722 * @return float
2723 */
2724 public function get_line_subtotal( AbstractOrderItem $item, bool $inc_tax = false, bool $round = true ): float {
2725 $subtotal = 0;
2726
2727 if ( is_callable( array( $item, 'get_subtotal' ) ) ) {
2728 if ( $inc_tax ) {
2729 $subtotal = (float) $item->get_subtotal() + (float) $item->get_subtotal_tax();
2730 } else {
2731 $subtotal = (float) $item->get_subtotal();
2732 }
2733
2734 $subtotal = $round ? NumberUtil::round( $subtotal, Formatting::get_price_decimals() ) : $subtotal;
2735 }
2736
2737 return apply_filters( 'storeengine/order_amount_line_subtotal', $subtotal, $this, $item, $inc_tax, $round );
2738 }
2739
2740 /**
2741 * Calculate item cost - useful for gateways.
2742 *
2743 * @param object $item Item to get total from.
2744 * @param bool $inc_tax (default: false).
2745 * @param bool $round (default: true).
2746 *
2747 * @return float
2748 */
2749 public function get_item_total( object $item, bool $inc_tax = false, bool $round = true ): float {
2750 $total = 0;
2751
2752 if ( is_callable( array( $item, 'get_total' ) ) && $item->get_quantity() ) {
2753 if ( $inc_tax ) {
2754 $total = ( (float) $item->get_total() + (float) $item->get_total_tax() ) / $item->get_quantity();
2755 } else {
2756 $total = ( (float) $item->get_total() ) / $item->get_quantity();
2757 }
2758
2759 $total = $round ? NumberUtil::round( $total, Formatting::get_price_decimals() ) : $total;
2760 }
2761
2762 return apply_filters( 'storeengine/order_amount_item_total', $total, $this, $item, $inc_tax, $round );
2763 }
2764
2765 /**
2766 * Calculate line total - useful for gateways.
2767 *
2768 * @param object $item Item to get total from.
2769 * @param bool $inc_tax (default: false).
2770 * @param bool $round (default: true).
2771 *
2772 * @return float
2773 */
2774 public function get_line_total( object $item, bool $inc_tax = false, bool $round = true ): float {
2775 $total = 0;
2776
2777 if ( is_callable( array( $item, 'get_total' ) ) ) {
2778 // Check if we need to add line tax to the line total.
2779 $total = $inc_tax ? (float) $item->get_total() + (float) $item->get_total_tax() : (float) $item->get_total();
2780
2781 // Check if we need to round.
2782 $total = $round ? NumberUtil::round( $total, Formatting::get_price_decimals() ) : $total;
2783 }
2784
2785 return apply_filters( 'storeengine/order_amount_line_total', $total, $this, $item, $inc_tax, $round );
2786 }
2787
2788 /**
2789 * Get item tax - useful for gateways.
2790 *
2791 * @param mixed $item Item to get total from.
2792 * @param bool $round (default: true).
2793 *
2794 * @return float
2795 */
2796 public function get_item_tax( $item, bool $round = true ): float {
2797 $tax = 0;
2798
2799 if ( is_callable( array( $item, 'get_total_tax' ) ) && $item->get_quantity() ) {
2800 $tax = $item->get_total_tax() / $item->get_quantity();
2801 $tax = $round ? Formatting::round_tax_total( $tax ) : $tax;
2802 }
2803
2804 return apply_filters( 'storeengine/order_amount_item_tax', $tax, $item, $round, $this );
2805 }
2806
2807 /**
2808 * Get line tax - useful for gateways.
2809 *
2810 * @param mixed $item Item to get total from.
2811 *
2812 * @return float
2813 */
2814 public function get_line_tax( $item ): float {
2815 return apply_filters( 'storeengine/order_amount_line_tax', is_callable( array(
2816 $item,
2817 'get_total_tax',
2818 ) ) ? Formatting::round_tax_total( $item->get_total_tax() ) : 0, $item, $this );
2819 }
2820
2821 /**
2822 * Gets line subtotal - formatted for display.
2823 *
2824 * @param AbstractOrderItem $item Item to get total from.
2825 * @param string $tax_display Incl or excl tax display mode.
2826 *
2827 * @return string
2828 */
2829 public function get_formatted_line_subtotal( AbstractOrderItem $item, string $tax_display = '' ): ?string {
2830 $tax_display = $tax_display ? $tax_display : Helper::get_settings( 'tax_display_cart', 'excl' );
2831
2832 if ( 'excl' === $tax_display ) {
2833 $ex_tax_label = $this->get_prices_include_tax() ? 1 : 0;
2834
2835 $subtotal = Formatting::price(
2836 $this->get_line_subtotal( $item ),
2837 [
2838 'ex_tax_label' => $ex_tax_label,
2839 'currency' => $this->get_currency(),
2840 ]
2841 );
2842 } else {
2843 $subtotal = Formatting::price( $this->get_line_subtotal( $item, true ), [ 'currency' => $this->get_currency() ] );
2844 }
2845
2846 return apply_filters( 'storeengine/order_formatted_line_subtotal', $subtotal, $item, $this );
2847 }
2848
2849 /**
2850 * Gets order total - formatted for display.
2851 *
2852 * @return string
2853 */
2854 public function get_formatted_order_total(): ?string {
2855 $formatted_total = Formatting::price( $this->get_total(), [ 'currency' => $this->get_currency() ] );
2856
2857 return apply_filters( 'storeengine/get_formatted_order_total', $formatted_total, $this );
2858 }
2859
2860 /**
2861 * Gets subtotal - subtotal is shown before discounts, but with localised taxes.
2862 *
2863 * @param bool $compound (default: false).
2864 * @param string $tax_display (default: the tax_display_cart value).
2865 *
2866 * @return string
2867 */
2868 public function get_subtotal_to_display( $compound = false, $tax_display = '' ): ?string {
2869 $tax_display = $tax_display ?: Helper::get_settings( 'tax_display_cart', 'excl' );
2870 $subtotal = (float) $this->get_cart_subtotal_for_order();
2871
2872 if ( ! $compound ) {
2873 if ( 'incl' === $tax_display ) {
2874 $subtotal_taxes = 0;
2875 foreach ( $this->get_items() as $item ) {
2876 $subtotal_taxes += self::round_line_tax( (float) $item->get_subtotal_tax(), false );
2877 }
2878 $subtotal += Formatting::round_tax_total( $subtotal_taxes );
2879 }
2880
2881 $subtotal = Formatting::price( $subtotal, [ 'currency' => $this->get_currency() ] );
2882
2883 if ( 'excl' === $tax_display && $this->get_prices_include_tax() && TaxUtil::is_tax_enabled() ) {
2884 $subtotal .= ' <small class="tax_label">' . Countries::init()->ex_tax_or_vat() . '</small>';
2885 }
2886 } else {
2887 if ( 'incl' === $tax_display ) {
2888 return '';
2889 }
2890
2891 // Add Shipping Costs.
2892 $subtotal += (float) $this->get_shipping_total();
2893
2894 // Remove non-compound taxes.
2895 foreach ( $this->get_taxes() as $tax ) {
2896 if ( $tax->is_compound() ) {
2897 continue;
2898 }
2899 $subtotal = $subtotal + (float) $tax->get_tax_total() + (float) $tax->get_shipping_tax_total();
2900 }
2901
2902 // Remove discounts.
2903 $subtotal = $subtotal - (float) $this->get_total_discount();
2904 $subtotal = Formatting::price( $subtotal, [ 'currency' => $this->get_currency() ] );
2905 }
2906
2907 return apply_filters( 'storeengine/order_subtotal_to_display', $subtotal, $compound, $this );
2908 }
2909
2910 /**
2911 * Gets shipping (formatted).
2912 *
2913 * @param string $tax_display Excl or incl tax display mode.
2914 *
2915 * @return string
2916 */
2917 public function get_shipping_to_display( string $tax_display = '' ) {
2918 $tax_display = $tax_display ?: Helper::get_settings( 'tax_display_cart', 'excl' );
2919
2920 if ( 0 < abs( (float) $this->get_shipping_total() ) ) {
2921 if ( 'excl' === $tax_display ) {
2922
2923 // Show shipping excluding tax.
2924 $shipping = Formatting::price( $this->get_shipping_total(), [ 'currency' => $this->get_currency() ] );
2925
2926 if ( (float) $this->get_shipping_tax() > 0 && $this->get_prices_include_tax() ) {
2927 $shipping .= apply_filters( 'storeengine/order_shipping_to_display_tax_label', '&nbsp;<small class="tax_label">' . Countries::init()->ex_tax_or_vat() . '</small>', $this, $tax_display );
2928 }
2929 } else {
2930
2931 // Show shipping including tax.
2932 $shipping = Formatting::price( (float) $this->get_shipping_total() + (float) $this->get_shipping_tax(), [ 'currency' => $this->get_currency() ] );
2933
2934 if ( (float) $this->get_shipping_tax() > 0 && ! $this->get_prices_include_tax() ) {
2935 $shipping .= apply_filters( 'storeengine/order_shipping_to_display_tax_label', '&nbsp;<small class="tax_label">' . Countries::init()->inc_tax_or_vat() . '</small>', $this, $tax_display );
2936 }
2937 }
2938
2939 /* translators: %s: method */
2940 $shipping .= apply_filters( 'storeengine/order_shipping_to_display_shipped_via', '&nbsp;<small class="shipped_via">' . sprintf( esc_html__( 'via %s', 'storeengine' ), $this->get_shipping_method() ) . '</small>', $this );
2941 } elseif ( $this->get_shipping_method() ) {
2942 $shipping = $this->get_shipping_method();
2943 } else {
2944 $shipping = __( 'Free!', 'storeengine' );
2945 }
2946
2947 return apply_filters( 'storeengine/order_shipping_to_display', $shipping, $this, $tax_display );
2948 }
2949
2950 /**
2951 * Get the discount amount (formatted).
2952 *
2953 * @param string $tax_display Excl or incl tax display mode.
2954 *
2955 * @return string
2956 */
2957 public function get_discount_to_display( string $tax_display = '' ): ?string {
2958 $tax_display = $tax_display ? $tax_display : Helper::get_settings( 'tax_display_cart', 'excl' );
2959
2960 /**
2961 * Filter the discount amount to display.
2962 */
2963 return apply_filters( 'storeengine/order_discount_to_display', Formatting::price( $this->get_total_discount( 'excl' === $tax_display ), [ 'currency' => $this->get_currency() ] ), $this );
2964 }
2965
2966 /**
2967 * Add total row for subtotal.
2968 *
2969 * @param array $total_rows Reference to total rows array.
2970 * @param string $tax_display Excl or incl tax display mode.
2971 */
2972 protected function add_order_item_totals_subtotal_row( &$total_rows, $tax_display ) {
2973 $subtotal = $this->get_subtotal_to_display( false, $tax_display );
2974
2975 if ( $subtotal ) {
2976 $total_rows['cart_subtotal'] = [
2977 'type' => 'subtotal',
2978 'label' => __( 'Subtotal:', 'storeengine' ),
2979 'value' => $subtotal,
2980 ];
2981 }
2982 }
2983
2984 /**
2985 * Add total row for discounts.
2986 *
2987 * @param array $total_rows Reference to total rows array.
2988 * @param string $tax_display Excl or incl tax display mode.
2989 */
2990 protected function add_order_item_totals_discount_row( array &$total_rows, string $tax_display ) {
2991 if ( $this->get_total_discount() > 0 ) {
2992 // Name the applied coupon(s) so the customer can see which code was
2993 // used — shown in the order confirmation email and every order view.
2994 $codes = array_filter( array_map( 'strval', $this->get_coupon_codes() ) );
2995 $label = $codes
2996 ? sprintf(
2997 /* translators: %s: comma-separated coupon code(s) applied to the order. */
2998 __( 'Discount (%s):', 'storeengine' ),
2999 strtoupper( implode( ', ', $codes ) )
3000 )
3001 : __( 'Discount:', 'storeengine' );
3002
3003 $total_rows['discount'] = [
3004 'type' => 'discount',
3005 'label' => $label,
3006 'value' => '-' . $this->get_discount_to_display( $tax_display ),
3007 ];
3008 }
3009 }
3010
3011 /**
3012 * Add total row for shipping.
3013 *
3014 * @param array $total_rows Reference to total rows array.
3015 * @param string $tax_display Excl or incl tax display mode.
3016 */
3017 protected function add_order_item_totals_shipping_row( array &$total_rows, string $tax_display ) {
3018 if ( $this->get_shipping_method() ) {
3019 $total_rows['shipping'] = [
3020 'type' => 'shipping',
3021 'label' => __( 'Shipping:', 'storeengine' ),
3022 'value' => $this->get_shipping_to_display( $tax_display ),
3023 'meta' => $this->get_shipping_method(),
3024 ];
3025 }
3026 }
3027
3028 /**
3029 * Add total row for fees.
3030 *
3031 * @param array $total_rows Reference to total rows array.
3032 * @param string $tax_display Excl or incl tax display mode.
3033 */
3034 protected function add_order_item_totals_fee_rows( array &$total_rows, string $tax_display ) {
3035 $fees = $this->get_fees();
3036
3037 if ( $fees ) {
3038 foreach ( $fees as $id => $fee ) {
3039 if ( apply_filters( 'storeengine/get_order_item_totals_excl_free_fees', empty( $fee->get_total() ) && empty( $fee->get_total_tax() ), $id ) ) {
3040 continue;
3041 }
3042 $total_rows[ 'fee_' . $fee->get_id() ] = [
3043 'type' => 'fee',
3044 'label' => $fee->get_name() . ':',
3045 'value' => Formatting::price( 'excl' === $tax_display ? (float) $fee->get_total() : (float) $fee->get_total() + (float) $fee->get_total_tax(), [ 'currency' => $this->get_currency() ] ),
3046 ];
3047 }
3048 }
3049 }
3050
3051 /**
3052 * Add total row for taxes.
3053 *
3054 * @param array $total_rows Reference to total rows array.
3055 * @param string $tax_display Excl or incl tax display mode.
3056 */
3057 protected function add_order_item_totals_tax_rows( array &$total_rows, string $tax_display ) {
3058 // Tax for tax exclusive prices.
3059 if ( 'excl' === $tax_display && TaxUtil::is_tax_enabled() ) {
3060 if ( 'itemized' === Helper::get_settings( 'tax_total_display' ) ) {
3061 foreach ( $this->get_tax_totals() as $code => $tax ) {
3062 $total_rows[ sanitize_title( $code ) ] = [
3063 'type' => 'tax',
3064 'label' => $tax->label . ':',
3065 'value' => $tax->formatted_amount,
3066 ];
3067 }
3068 } else {
3069 $total_rows['tax'] = [
3070 'type' => 'tax',
3071 'label' => Countries::init()->tax_or_vat() . ':',
3072 'value' => Formatting::price( $this->get_total_tax(), [ 'currency' => $this->get_currency() ] ),
3073 ];
3074 }
3075 }
3076 }
3077
3078 /**
3079 * Add total row for grand total.
3080 *
3081 * @param array $total_rows Reference to total rows array.
3082 * @param string $tax_display Excl or incl tax display mode.
3083 */
3084 protected function add_order_item_totals_total_row( array &$total_rows, string $tax_display ) {
3085 $total_rows['order_total'] = array(
3086 'type' => 'total',
3087 'label' => __( 'Total:', 'storeengine' ),
3088 'value' => $this->get_formatted_order_total( $tax_display ),
3089 );
3090 }
3091
3092 /**
3093 * Get totals for display on pages and in emails.
3094 *
3095 * @param mixed $tax_display Excl or incl tax display mode.
3096 *
3097 * @return array
3098 */
3099 public function get_order_item_totals( string $tax_display = '' ) {
3100 $tax_display = $tax_display ? $tax_display : Helper::get_settings( 'tax_display_cart', 'excl' );
3101 $total_rows = [];
3102
3103 $this->add_order_item_totals_subtotal_row( $total_rows, $tax_display );
3104 $this->add_order_item_totals_discount_row( $total_rows, $tax_display );
3105 $this->add_order_item_totals_shipping_row( $total_rows, $tax_display );
3106 $this->add_order_item_totals_fee_rows( $total_rows, $tax_display );
3107 $this->add_order_item_totals_tax_rows( $total_rows, $tax_display );
3108 $this->add_order_item_totals_total_row( $total_rows, $tax_display );
3109
3110 return apply_filters( 'storeengine/get_order_item_totals', $total_rows, $this, $tax_display );
3111 }
3112
3113 /*
3114 |--------------------------------------------------------------------------
3115 | Conditionals
3116 |--------------------------------------------------------------------------
3117 |
3118 | Checks if a condition is true or false.
3119 |
3120 */
3121
3122 /**
3123 * Checks the order status against a passed in status.
3124 *
3125 * @param array|string $status Status to check.
3126 *
3127 * @return bool
3128 */
3129 public function has_status( $status ): bool {
3130 /**
3131 * @deprecated hook. use storeengine/order/has_status
3132 */
3133 return apply_filters( 'storeengine/order_has_status', parent::has_status( $status ), $this, $status );
3134 }
3135
3136 /**
3137 * Check whether this order has a specific shipping method or not.
3138 *
3139 * @param string $method_id Method ID to check.
3140 *
3141 * @return bool
3142 */
3143 public function has_shipping_method( string $method_id ): bool {
3144 foreach ( $this->get_shipping_methods() as $shipping_method ) {
3145 if ( strpos( $shipping_method->get_method_id( 'edit' ), $method_id ) === 0 ) {
3146 return true;
3147 }
3148 }
3149
3150 return false;
3151 }
3152
3153 /**
3154 * Returns true if the order contains a free product.
3155 *
3156 * @return bool
3157 */
3158 public function has_free_item(): bool {
3159 foreach ( $this->get_items() as $item ) {
3160 if ( ! $item->get_total() ) {
3161 return true;
3162 }
3163 }
3164
3165 return false;
3166 }
3167
3168 /**
3169 * Get order title.
3170 *
3171 * @return string Order title.
3172 */
3173 public function get_title( string $context = 'view' ): ?string {
3174 $title = $this->get_meta( 'title', true, $context );
3175
3176 return $title ?: __( 'Order', 'storeengine' );
3177 }
3178
3179
3180 // ------------------>
3181
3182 public function add_tax( $tax_rate_id ) {
3183 if ( $tax_rate_id && apply_filters( 'storeengine/cart/remove_taxes_zero_rate_id', 'zero-rated' ) === $tax_rate_id ) {
3184 return;
3185 }
3186
3187 $item = new OrderItemTax();
3188 $item->set_props( [
3189 'rate_id' => $tax_rate_id,
3190 'order_id' => $this->get_id(),
3191 'tax_total' => Helper::cart()->get_tax_amount( $tax_rate_id ),
3192 'shipping_tax_total' => Helper::cart()->get_shipping_tax_amount( $tax_rate_id ),
3193 'rate_code' => Tax::get_rate_code( $tax_rate_id ),
3194 'label' => Tax::get_rate_label( $tax_rate_id ),
3195 'compound' => Tax::is_compound( $tax_rate_id ),
3196 'rate_percent' => Tax::get_rate_percent_value( $tax_rate_id ),
3197 ] );
3198
3199 /**
3200 * Fires after adding tax on Order.
3201 *
3202 * @param OrderItemTax $item ItemTax object.
3203 * @param int $tax_rate_id Tax rate id.
3204 * @param Order $this Order instance.
3205 */
3206 do_action( 'storeengine/order/checkout/create_order_tax_item', $item, $tax_rate_id, $this );
3207
3208 $this->add_item( $item );
3209 }
3210
3211 //---
3212
3213 public function clear_items() {
3214 global $wpdb;
3215
3216 $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
3217 "DELETE items, meta
3218 FROM {$wpdb->prefix}storeengine_order_items AS items
3219 LEFT JOIN {$wpdb->prefix}storeengine_order_item_meta AS meta
3220 ON items.order_item_id = meta.order_item_id
3221 WHERE items.order_id = %d",
3222 $this->get_id()
3223 ) );
3224 }
3225
3226 public function calculate( $and_taxes = true ) {
3227 $this->calculate_totals( $and_taxes );
3228 }
3229
3230 protected function items_query(): ?string {
3231 global $wpdb;
3232
3233 return "
3234 SELECT
3235 items.order_item_id,
3236 items.order_item_name
3237 FROM {$wpdb->prefix}storeengine_order_items AS items
3238 LEFT JOIN {$wpdb->prefix}storeengine_order_item_meta AS meta
3239 ON items.order_item_id = meta.order_item_id
3240 WHERE items.order_id = %d AND items.order_item_type = %s
3241 GROUP BY items.order_item_id;";
3242 }
3243
3244 /**
3245 * Read order items of a specific type from the database for this order.
3246 *
3247 * @param string $type Order item type.
3248 *
3249 * @return array
3250 */
3251 public function read_items( string $type ): array {
3252 global $wpdb;
3253
3254 // When the order is not yet saved, we cannot get the items from DB. Trying to do so will risk reading items of different orders that were saved incorrectly.
3255 if ( 0 === $this->get_id() ) {
3256 return [];
3257 }
3258
3259 // Get from cache if available.
3260 $items = 0 < $this->get_id() ? wp_cache_get( 'order-items-' . $this->get_id(), 'orders' ) : false;
3261
3262 if ( false === $items ) {
3263 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- adding items into cache.
3264 $items = $wpdb->get_results(
3265 $wpdb->prepare( "SELECT order_item_type, order_item_id, order_id, order_item_name FROM {$wpdb->prefix}storeengine_order_items WHERE order_id = %d ORDER BY order_item_id;", $this->get_id() )
3266 );
3267 foreach ( $items as $item ) {
3268 wp_cache_set( 'item-' . $item->order_item_id, $item, 'order-items' );
3269 }
3270 if ( 0 < $this->get_id() ) {
3271 wp_cache_set( 'order-items-' . $this->get_id(), $items, 'orders' );
3272 }
3273 }
3274
3275 $items = wp_list_filter( $items, [ 'order_item_type' => $type ] );
3276
3277 if ( ! empty( $items ) ) {
3278 $items = array_map(
3279 [ Order::class, 'get_order_item' ],
3280 array_combine( wp_list_pluck( $items, 'order_item_id' ), $items )
3281 );
3282 } else {
3283 $items = [];
3284 }
3285
3286 return $items;
3287 }
3288
3289 /**
3290 * Get order item.
3291 *
3292 * @param int|string|AbstractOrderItem $item_id Order item ID to get.
3293 *
3294 * @return AbstractOrderItem|false if not found
3295 */
3296 public static function get_order_item( $item_id = 0 ) {
3297 if ( is_numeric( $item_id ) ) {
3298 $item_type = AbstractOrderItem::get_order_item_type( absint( $item_id ) );
3299 $id = $item_id;
3300 } elseif ( $item_id instanceof AbstractOrderItem ) {
3301 $item_type = $item_id->get_type();
3302 $id = $item_id->get_id();
3303 } elseif ( is_object( $item_id ) && ! empty( $item_id->order_item_id ) && ! empty( $item_id->order_item_type ) ) {
3304 $id = $item_id->order_item_id;
3305 $item_type = $item_id->order_item_type;
3306 } else {
3307 $item_type = false;
3308 $id = false;
3309 }
3310
3311 if ( $id && $item_type ) {
3312 $classname = false;
3313 switch ( $item_type ) {
3314 case 'line_item':
3315 case 'product':
3316 $classname = OrderItemProduct::class;
3317 break;
3318 case 'coupon':
3319 $classname = OrderItemCoupon::class;
3320 break;
3321 case 'fee':
3322 $classname = OrderItemFee::class;
3323 break;
3324 case 'shipping':
3325 $classname = OrderItemShipping::class;
3326 break;
3327 case 'tax':
3328 $classname = OrderItemTax::class;
3329 break;
3330 }
3331
3332 $classname = apply_filters( 'storeengine/get_order_item_classname', $classname, $item_type, $id );
3333
3334 if ( $classname && class_exists( $classname ) ) {
3335 try {
3336 return new $classname( $id );
3337 } catch ( Exception $e ) {
3338 Helper::log_error( $e );
3339 return false;
3340 }
3341 }
3342 }
3343
3344 return false;
3345 }
3346
3347 public function get_order_email( string $context = 'view' ) {
3348 return $this->get_prop( 'order_email', $context );
3349 }
3350
3351 public function set_order_email( ?string $value = '' ) {
3352 $value = $value ?? '';
3353
3354 if ( $value && ! is_email( $value ) ) {
3355 $this->error( 'order_email', __( 'Invalid order email address.', 'storeengine' ) );
3356 }
3357
3358 $this->set_prop( 'order_email', sanitize_email( $value ) );
3359 }
3360
3361 /**
3362 * Get customer_id.
3363 *
3364 * @param string $context What the value is for. Valid values are view and edit.
3365 *
3366 * @return int
3367 */
3368 public function get_customer_id( string $context = 'view' ): int {
3369 return (int) $this->get_prop( 'customer_id', $context );
3370 }
3371
3372 /**
3373 * Set customer id.
3374 *
3375 * @param int|string $value Customer ID.
3376 */
3377 public function set_customer_id( $value ) {
3378 $this->set_prop( 'customer_id', absint( $value ) );
3379 }
3380
3381 public function get_customer() {
3382 if ( $this->get_customer_id() ) {
3383 $customer = new Customer( $this->get_customer_id() );
3384
3385 return $customer->get_id() ? $customer : false;
3386 }
3387
3388 return false;
3389 }
3390
3391 /**
3392 * @throws StoreEngineInvalidOrderStatusException
3393 */
3394 public function get_status_title(): string {
3395 $order_status = new OrderContext( $this->get_status() );
3396
3397 return $order_status->get_order_status_title();
3398 }
3399
3400 // operational_data methods ----------
3401
3402 public function get_coupon_usages_are_counted( string $context = 'view' ): bool {
3403 return Formatting::string_to_bool( $this->get_prop( 'coupon_usages_are_counted', $context ) );
3404 }
3405
3406 public function get_download_permission_granted( string $context = 'view' ): bool {
3407 return Formatting::string_to_bool( $this->get_prop( 'download_permission_granted', $context ) );
3408 }
3409
3410 public function get_cart_hash( string $context = 'view' ) {
3411 return $this->get_prop( 'cart_hash', $context );
3412 }
3413
3414 public function get_new_order_email_sent( string $context = 'view' ): bool {
3415 return Formatting::string_to_bool( $this->get_prop( 'new_order_email_sent', $context ) );
3416 }
3417
3418 public function get_order_key( string $context = 'view' ) {
3419 $value = $this->get_prop( 'order_key', $context );
3420
3421 if ( $value ) {
3422 return $value;
3423 }
3424
3425 return self::generate_order_key();
3426 }
3427
3428 public function get_order_stock_reduced( string $context = 'view' ): bool {
3429 return Formatting::string_to_bool( $this->get_prop( 'order_stock_reduced', $context ) );
3430 }
3431
3432 public function get_date_created_gmt( string $context = 'view' ): ?StoreengineDatetime {
3433 return $this->get_prop( 'date_created_gmt', $context );
3434 }
3435
3436 public function get_date_updated_gmt( string $context = 'view' ): ?StoreengineDatetime {
3437 return $this->get_prop( 'date_updated_gmt', $context );
3438 }
3439
3440 public function get_date_paid_gmt( string $context = 'view' ) {
3441 return $this->get_prop( 'date_paid_gmt', $context );
3442 }
3443
3444 public function get_date_completed_gmt( string $context = 'view' ) {
3445 return $this->get_prop( 'date_completed_gmt', $context );
3446 }
3447
3448 public function get_shipping_tax_amount( string $context = 'view' ) {
3449 return $this->get_prop( 'shipping_tax_amount', $context );
3450 }
3451
3452 public function get_shipping_total_amount( string $context = 'view' ) {
3453 return $this->get_prop( 'shipping_total_amount', $context );
3454 }
3455
3456 public function get_discount_tax_amount( string $context = 'view' ) {
3457 return $this->get_prop( 'discount_tax_amount', $context );
3458 }
3459
3460 public function get_discount_total_amount( string $context = 'view' ) {
3461 return $this->get_prop( 'discount_total_amount', $context );
3462 }
3463
3464 public function get_recorded_sales( string $context = 'view' ): bool {
3465 return Formatting::string_to_bool( $this->get_prop( 'recorded_sales', $context ) );
3466 }
3467
3468 // ----------
3469
3470 public function set_coupon_usages_are_counted( $value ) {
3471 $this->set_prop( 'coupon_usages_are_counted', Formatting::string_to_bool( $value ) );
3472 }
3473
3474 public function set_download_permission_granted( $value ) {
3475 $this->set_prop( 'download_permission_granted', Formatting::string_to_bool( $value ) );
3476 }
3477
3478 public function set_cart_hash( $value ) {
3479 $this->set_prop( 'cart_hash', $value );
3480 }
3481
3482 public function set_new_order_email_sent( $value ) {
3483 $this->set_prop( 'new_order_email_sent', Formatting::string_to_bool( $value ) );
3484 }
3485
3486 public function set_order_key( $value ) {
3487 $this->set_prop( 'order_key', $value );
3488 }
3489
3490 public function set_order_stock_reduced( $value ) {
3491 $this->set_prop( 'order_stock_reduced', Formatting::string_to_bool( $value ) );
3492 }
3493
3494 public function set_date_created_gmt( $value ) {
3495 $this->set_date_prop( 'date_created_gmt', $value );
3496 }
3497
3498 public function set_date_updated_gmt( $value ) {
3499 $this->set_date_prop( 'date_updated_gmt', $value );
3500 }
3501
3502 public function set_date_paid_gmt( $value ) {
3503 $this->set_date_prop( 'date_paid_gmt', $value );
3504 }
3505
3506 /**
3507 * Set date paid.
3508 *
3509 * @param string|integer|null $date UTC timestamp, or ISO 8601 DateTime. If the DateTime string has no timezone or offset, WordPress site timezone will be assumed. Null if their is no date.
3510 *
3511 * @deprecated
3512 * @see set_date_paid_gmt
3513 */
3514 public function set_date_paid( $date = null ) {
3515 $this->set_date_prop( 'set_date_paid_gmt', $date );
3516 }
3517
3518 public function set_date_completed_gmt( $value ) {
3519 $this->set_date_prop( 'date_completed_gmt', $value );
3520 }
3521
3522 /**
3523 * Placeholder for reminding devs to use the _gmt version.
3524 *
3525 * @param $value
3526 *
3527 * @return void
3528 * @deprecated
3529 * @see set_date_completed_gmt()
3530 */
3531 public function set_date_completed( $value ) {
3532 $this->set_date_completed_gmt( $value );
3533 }
3534
3535 public function set_shipping_tax_amount( $value ) {
3536 $this->set_prop( 'shipping_tax_amount', $value );
3537 }
3538
3539 public function set_shipping_total_amount( $value ) {
3540 $this->set_prop( 'shipping_total_amount', $value );
3541 }
3542
3543 public function set_discount_tax_amount( $value ) {
3544 $this->set_prop( 'discount_tax_amount', $value );
3545 }
3546
3547 public function set_discount_total_amount( $value ) {
3548 $this->set_prop( 'discount_total_amount', $value );
3549 }
3550
3551 public function set_recorded_sales( $value ) {
3552 $this->set_prop( 'recorded_sales', Formatting::string_to_bool( $value ) );
3553 }
3554
3555 /**
3556 * Generate an order key with prefix.
3557 *
3558 * @param int $length By default, generates a 13 digit secret.
3559 * Length can't be less than 9 or greater than 91 due to db restrain.
3560 *
3561 * @return string The order key.
3562 */
3563 public static function generate_order_key( int $length = 13 ): string {
3564 if ( 9 > $length || 91 < $length ) {
3565 $length = 13;
3566 }
3567
3568 $key = wp_generate_password( $length, false );
3569
3570 return 'se_' . apply_filters( 'storeengine/generate_order_key', 'order_' . $key );
3571 }
3572
3573 public function clear_cache( bool $flush_collection = true ) {
3574 parent::clear_cache( $flush_collection );
3575 wp_cache_delete( 'order:draft:' . Helper::get_cart_hash_from_cookie(), $this->cache_group );
3576 wp_cache_delete( 'order:key:' . $this->get_order_key( 'edit' ), $this->cache_group );
3577 wp_cache_delete( Caching::get_cache_prefix( 'orders' ) . 'refunds' . $this->get_id(), $this->cache_group );
3578
3579 $this->clear_caches();
3580 }
3581
3582 protected function clear_caches() {
3583 if ( $this->get_customer_id() ) {
3584 global $wpdb;
3585 delete_user_meta( $this->get_customer_id(), '_money_spent_' . rtrim( $wpdb->get_blog_prefix(), '_' ) );
3586 delete_user_meta( $this->get_customer_id(), '_order_count_' . rtrim( $wpdb->get_blog_prefix(), '_' ) );
3587 delete_user_meta( $this->get_customer_id(), '_last_order_' . rtrim( $wpdb->get_blog_prefix(), '_' ) );
3588 }
3589
3590 Caching::get_transient_version( 'orders' );
3591 Caching::invalidate_cache_group( 'orders' );
3592
3593 wp_cache_delete( 'order-items-' . $this->get_id(), 'orders' );
3594 }
3595 }
3596