| 1 |
<?php |
| 2 |
/** |
| 3 |
* StoreEngine Shipping |
| 4 |
* |
| 5 |
* Handles shipping and loads shipping methods via hooks. |
| 6 |
* |
| 7 |
* @package StoreEngine |
| 8 |
*/ |
| 9 |
|
| 10 |
namespace StoreEngine\Shipping; |
| 11 |
|
| 12 |
use StoreEngine\Classes\Cart; |
| 13 |
use StoreEngine\Classes\Countries; |
| 14 |
use StoreEngine\Classes\Exceptions\StoreEngineException; |
| 15 |
use StoreEngine\Shipping\Methods\ShippingFlatRate; |
| 16 |
use StoreEngine\Shipping\Methods\ShippingMethod; |
| 17 |
use StoreEngine\Traits\Singleton; |
| 18 |
use StoreEngine\Utils\Caching; |
| 19 |
use StoreEngine\Utils\Helper; |
| 20 |
use StoreEngine\Utils\ShippingUtils; |
| 21 |
|
| 22 |
if ( ! defined( 'ABSPATH' ) ) { |
| 23 |
exit; // Exit if accessed directly. |
| 24 |
} |
| 25 |
|
| 26 |
/** |
| 27 |
* Core shipping engine. |
| 28 |
*/ |
| 29 |
final class Shipping { |
| 30 |
use Singleton; |
| 31 |
|
| 32 |
/** |
| 33 |
* True if shipping is enabled. |
| 34 |
* |
| 35 |
* @var bool |
| 36 |
*/ |
| 37 |
public $enabled = false; |
| 38 |
|
| 39 |
/** |
| 40 |
* Stores methods loaded into StoreEngine. |
| 41 |
* |
| 42 |
* @var array|null |
| 43 |
*/ |
| 44 |
public $shipping_methods = null; |
| 45 |
|
| 46 |
/** |
| 47 |
* Stores the shipping classes. |
| 48 |
* |
| 49 |
* @var array |
| 50 |
*/ |
| 51 |
public array $shipping_classes = []; |
| 52 |
|
| 53 |
/** |
| 54 |
* Stores packages to ship and to get quotes for. |
| 55 |
* |
| 56 |
* @var array |
| 57 |
*/ |
| 58 |
public array $packages = []; |
| 59 |
|
| 60 |
/** |
| 61 |
* Magic getter. |
| 62 |
* |
| 63 |
* @param string $name Property name. |
| 64 |
* |
| 65 |
* @return mixed|void |
| 66 |
*/ |
| 67 |
public function __get( string $name ) { |
| 68 |
// Grab from cart for backwards compatibility with versions prior to 3.2. |
| 69 |
if ( 'shipping_total' === $name ) { |
| 70 |
return Helper::cart()->get_shipping_total(); |
| 71 |
} |
| 72 |
|
| 73 |
if ( 'shipping_taxes' === $name ) { |
| 74 |
return Helper::cart()->get_shipping_taxes(); |
| 75 |
} |
| 76 |
} |
| 77 |
|
| 78 |
/** |
| 79 |
* Initialize shipping. |
| 80 |
*/ |
| 81 |
public function __construct() { |
| 82 |
$this->enabled = ShippingUtils::is_shipping_enabled(); |
| 83 |
|
| 84 |
if ( $this->enabled ) { |
| 85 |
/** |
| 86 |
* Initialize shipping. |
| 87 |
*/ |
| 88 |
do_action( 'storeengine/shipping_init' ); |
| 89 |
} |
| 90 |
} |
| 91 |
|
| 92 |
/** |
| 93 |
* @return ShippingMethod[] |
| 94 |
*/ |
| 95 |
public function get_all_shipping_methods(): array { |
| 96 |
static $shipping_methods = []; |
| 97 |
|
| 98 |
if ( empty( $shipping_methods ) ) { |
| 99 |
foreach ( $this->get_shipping_method_class_names() as $method_id => $method_class ) { |
| 100 |
if ( ! is_object( $method_class ) ) { |
| 101 |
if ( ! class_exists( $method_class ) ) { |
| 102 |
continue; |
| 103 |
} |
| 104 |
$method = new $method_class(); |
| 105 |
if ( is_null( $shipping_methods ) ) { |
| 106 |
$shipping_methods = []; |
| 107 |
} |
| 108 |
$shipping_methods[ $method->get_id() ] = $method; |
| 109 |
} |
| 110 |
} |
| 111 |
} |
| 112 |
|
| 113 |
return $shipping_methods; |
| 114 |
} |
| 115 |
|
| 116 |
/** |
| 117 |
* Shipping methods register themselves by returning their main class name through the shipping-methods registration filter. |
| 118 |
* |
| 119 |
* @return array |
| 120 |
*/ |
| 121 |
public function get_shipping_method_class_names(): array { |
| 122 |
// Unique Method ID => Method Class name. |
| 123 |
$shipping_methods = [ |
| 124 |
'flat_rate' => ShippingFlatRate::class, |
| 125 |
]; |
| 126 |
|
| 127 |
return apply_filters( 'storeengine/shipping/methods', $shipping_methods ); |
| 128 |
} |
| 129 |
|
| 130 |
/** |
| 131 |
* Loads all shipping methods which are hooked in. |
| 132 |
* If a $package is passed, some methods may add themselves conditionally and zones will be used. |
| 133 |
* |
| 134 |
* @param array $package Package information. |
| 135 |
* |
| 136 |
* @return ShippingMethod[] |
| 137 |
*/ |
| 138 |
public function load_shipping_methods( $package = [] ) { |
| 139 |
if ( ! empty( $package ) ) { |
| 140 |
try { |
| 141 |
$shipping_zone = ShippingZones::get_zone_matching_package( $package ); |
| 142 |
$this->shipping_methods = $shipping_zone->get_shipping_methods( true ); |
| 143 |
} catch ( StoreEngineException $e ) { |
| 144 |
// mostly if zone is not in db (maybe deleted). |
| 145 |
// @TODO add error logger. |
| 146 |
$this->shipping_methods = []; |
| 147 |
} |
| 148 |
} else { |
| 149 |
$this->shipping_methods = []; |
| 150 |
} |
| 151 |
|
| 152 |
// For the settings in the backend, and for non-shipping zone methods, we still need to load any registered classes here. |
| 153 |
foreach ( $this->get_shipping_method_class_names() as $method_id => $method_class ) { |
| 154 |
$this->register_shipping_method( $method_class ); |
| 155 |
} |
| 156 |
|
| 157 |
// Methods can register themselves manually through this hook if necessary. |
| 158 |
do_action( 'storeengine/shipping/load_shipping_methods', $package ); |
| 159 |
|
| 160 |
// Return loaded methods. |
| 161 |
return $this->get_shipping_methods(); |
| 162 |
} |
| 163 |
|
| 164 |
/** |
| 165 |
* Register a shipping method. |
| 166 |
* |
| 167 |
* @param object|string $method Either the name of the method's class, or an instance of the method's class. |
| 168 |
* |
| 169 |
* @return bool|void |
| 170 |
*/ |
| 171 |
public function register_shipping_method( $method ) { |
| 172 |
if ( ! is_object( $method ) ) { |
| 173 |
if ( ! class_exists( $method ) ) { |
| 174 |
return false; |
| 175 |
} |
| 176 |
$method = new $method(); |
| 177 |
} |
| 178 |
if ( is_null( $this->shipping_methods ) ) { |
| 179 |
$this->shipping_methods = []; |
| 180 |
} |
| 181 |
$this->shipping_methods[ $method->get_id() ] = $method; |
| 182 |
} |
| 183 |
|
| 184 |
/** |
| 185 |
* Unregister shipping methods. |
| 186 |
*/ |
| 187 |
public function unregister_shipping_methods() { |
| 188 |
$this->shipping_methods = null; |
| 189 |
} |
| 190 |
|
| 191 |
/** |
| 192 |
* Returns all registered shipping methods for usage. |
| 193 |
* |
| 194 |
* @return ShippingMethod[] |
| 195 |
*/ |
| 196 |
public function get_shipping_methods() { |
| 197 |
if ( is_null( $this->shipping_methods ) ) { |
| 198 |
$this->load_shipping_methods(); |
| 199 |
} |
| 200 |
|
| 201 |
return $this->shipping_methods; |
| 202 |
} |
| 203 |
|
| 204 |
/** |
| 205 |
* Get an array of shipping classes. |
| 206 |
* |
| 207 |
* @return array |
| 208 |
*/ |
| 209 |
public function get_shipping_classes(): array { |
| 210 |
if ( empty( $this->shipping_classes ) ) { |
| 211 |
$classes = get_terms( [ |
| 212 |
'taxonomy' => 'product_shipping_class', |
| 213 |
'hide_empty' => false, |
| 214 |
'orderby' => 'name', |
| 215 |
] ); |
| 216 |
$this->shipping_classes = ! is_wp_error( $classes ) ? $classes : []; |
| 217 |
} |
| 218 |
|
| 219 |
return apply_filters( 'storeengine/shipping/get_shipping_classes', $this->shipping_classes ); |
| 220 |
} |
| 221 |
|
| 222 |
/** |
| 223 |
* Calculate shipping for (multiple) packages of cart items. |
| 224 |
* |
| 225 |
* @param array $packages multidimensional array of cart items to calc shipping for. |
| 226 |
* @param ?Cart $cart Cart instance to avoid infinite looping. |
| 227 |
* |
| 228 |
* @return array Array of calculated packages. |
| 229 |
*/ |
| 230 |
public function calculate_shipping( array $packages = [], ?Cart $cart = null ): array { |
| 231 |
$this->packages = []; |
| 232 |
|
| 233 |
if ( ! $this->enabled || empty( $packages ) ) { |
| 234 |
return []; |
| 235 |
} |
| 236 |
|
| 237 |
// Calculate costs for passed packages. |
| 238 |
foreach ( $packages as $package_key => $package ) { |
| 239 |
$this->packages[ $package_key ] = $this->calculate_shipping_for_package( $cart, $package, $package_key ); |
| 240 |
} |
| 241 |
|
| 242 |
/** |
| 243 |
* Allow packages to be reorganized after calculating the shipping. |
| 244 |
* |
| 245 |
* This filter can be used to apply some extra manipulation after the shipping costs are calculated for the packages |
| 246 |
* but before StoreEngine does anything with them. A good example of usage is to merge the shipping methods for multiple |
| 247 |
* packages for marketplaces. |
| 248 |
* |
| 249 |
* @param array $packages The array of packages after shipping costs are calculated. |
| 250 |
*/ |
| 251 |
$this->packages = array_filter( (array) apply_filters( 'storeengine/shipping/packages', $this->packages ) ); |
| 252 |
|
| 253 |
return $this->packages; |
| 254 |
} |
| 255 |
|
| 256 |
/** |
| 257 |
* See if package is shippable. |
| 258 |
* |
| 259 |
* Packages are shippable until proven otherwise e.g. after getting a shipping country. |
| 260 |
* |
| 261 |
* @param array $package Package of cart items. |
| 262 |
* |
| 263 |
* @return bool |
| 264 |
*/ |
| 265 |
public function is_package_shippable( array $package ): bool { |
| 266 |
// Packages are shippable until proven otherwise. |
| 267 |
if ( empty( $package['destination']['country'] ) ) { |
| 268 |
return true; |
| 269 |
} |
| 270 |
|
| 271 |
$allowed = array_keys( Countries::init()->get_shipping_countries() ); |
| 272 |
|
| 273 |
return in_array( $package['destination']['country'], $allowed, true ); |
| 274 |
} |
| 275 |
|
| 276 |
/** |
| 277 |
* Calculate shipping rates for a package, |
| 278 |
* |
| 279 |
* Calculates each shipping methods cost. Rates are stored in the session based on the package hash to avoid re-calculation every page load. |
| 280 |
* |
| 281 |
* @param Cart $cart Cart instance. |
| 282 |
* @param array $package Package of cart items. |
| 283 |
* @param int|string $package_key Index of the package being calculated. Used to cache multiple package rates. |
| 284 |
* |
| 285 |
* @return array|bool |
| 286 |
*/ |
| 287 |
public function calculate_shipping_for_package( Cart $cart, array $package = [], $package_key = 0 ) { |
| 288 |
// If shipping is disabled or the package is invalid, return false. |
| 289 |
if ( ! $this->enabled || empty( $package ) ) { |
| 290 |
return false; |
| 291 |
} |
| 292 |
|
| 293 |
$package['rates'] = []; |
| 294 |
|
| 295 |
// If the package is not shippable, e.g. trying to ship to an invalid country, do not calculate rates. |
| 296 |
if ( ! $this->is_package_shippable( $package ) ) { |
| 297 |
return $package; |
| 298 |
} |
| 299 |
|
| 300 |
// Check if we need to recalculate shipping for this package. |
| 301 |
$package_to_hash = $package; |
| 302 |
|
| 303 |
// Remove data objects so hashes are consistent. |
| 304 |
foreach ( $package_to_hash['contents'] as $item_id => $item ) { |
| 305 |
if ( isset( $item->data ) ) { |
| 306 |
unset( $package_to_hash['contents'][ $item_id ]->data ); |
| 307 |
} |
| 308 |
} |
| 309 |
|
| 310 |
// Get rates stored in the session data for this package. |
| 311 |
$session_key = 'shipping_for_package_' . $package_key; |
| 312 |
$stored_rates = $cart->get_meta( $session_key ); |
| 313 |
|
| 314 |
// Calculate the hash for this package so we can tell if it's changed since last calculation. |
| 315 |
$package_hash = 'se_ship_' . md5( wp_json_encode( $package_to_hash ) . Caching::get_transient_version( 'shipping' ) ); |
| 316 |
|
| 317 |
if ( ! is_array( $stored_rates ) || $package_hash !== $stored_rates['package_hash'] ) { |
| 318 |
foreach ( $this->load_shipping_methods( $package ) as $shipping_method ) { |
| 319 |
if ( ! $shipping_method->supports( 'shipping-zones' ) || $shipping_method->get_instance_id() ) { |
| 320 |
/** |
| 321 |
* Fires before getting shipping rates for a package. |
| 322 |
* |
| 323 |
* @param array $package Package of cart items. |
| 324 |
* @param ShippingMethod $shipping_method Shipping method instance. |
| 325 |
*/ |
| 326 |
do_action( 'storeengine/shipping/before_get_rates_for_package', $package, $shipping_method ); |
| 327 |
|
| 328 |
// Use + instead of array_merge to maintain numeric keys. |
| 329 |
$package['rates'] = $package['rates'] + $shipping_method->get_rates_for_package( $package ); |
| 330 |
|
| 331 |
/** |
| 332 |
* Fires after getting shipping rates for a package. |
| 333 |
* |
| 334 |
* @param array $package Package of cart items. |
| 335 |
* @param ShippingMethod $shipping_method Shipping method instance. |
| 336 |
*/ |
| 337 |
do_action( 'storeengine/shipping/after_get_rates_for_package', $package, $shipping_method ); |
| 338 |
} |
| 339 |
} |
| 340 |
|
| 341 |
/** |
| 342 |
* Filter the calculated shipping rates. |
| 343 |
* |
| 344 |
* @param array $package ['rates'] Package rates. |
| 345 |
* @param array $package Package of cart items. |
| 346 |
*/ |
| 347 |
$package['rates'] = apply_filters( 'storeengine/shipping/package_rates', $package['rates'], $package ); |
| 348 |
|
| 349 |
// Package rates should be an array, if it was filtered into a non-array, reset it. Don't reset to the |
| 350 |
// unfiltered value, as e.g. a 3pd could have set it to "false" to remove rates. |
| 351 |
if ( ! is_array( $package['rates'] ) ) { |
| 352 |
$package['rates'] = []; |
| 353 |
} |
| 354 |
|
| 355 |
// Store in session to avoid recalculation. |
| 356 |
$cart->set_meta( |
| 357 |
$session_key, |
| 358 |
array( |
| 359 |
'package_hash' => $package_hash, |
| 360 |
'rates' => $package['rates'], |
| 361 |
) |
| 362 |
); |
| 363 |
} else { |
| 364 |
$package['rates'] = $stored_rates['rates']; |
| 365 |
} |
| 366 |
|
| 367 |
return $package; |
| 368 |
} |
| 369 |
|
| 370 |
/** |
| 371 |
* Get packages. |
| 372 |
* |
| 373 |
* @return array |
| 374 |
*/ |
| 375 |
public function get_packages(): array { |
| 376 |
return $this->packages; |
| 377 |
} |
| 378 |
|
| 379 |
/** |
| 380 |
* Reset shipping. |
| 381 |
* |
| 382 |
* Reset the totals for shipping as a whole. |
| 383 |
*/ |
| 384 |
public function reset_shipping() { |
| 385 |
$this->packages = []; |
| 386 |
} |
| 387 |
} |
| 388 |
|
| 389 |
// End of file shipping.php. |
| 390 |
|