PluginProbe
Packeta / 1.4
Packeta v1.4
2.3.2 2.3.1 trunk 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.3.0 1.3.1 1.3.2 1.4 1.4.1 1.4.2 1.4.3 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 All 56 releases
packeta / src / Packetery / Module / Checkout.php

Checkout.php in Packeta 1.4, at src/Packetery/Module/Checkout.php

1,029 lines 29.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Packeta plugin class for checkout.
4 *
5 * @package Packetery
6 */
7
8 declare( strict_types=1 );
9
10 namespace Packetery\Module;
11
12 use Packetery\Core;
13 use Packetery\Module\Options\Provider;
14 use PacketeryLatte\Engine;
15 use PacketeryNette\Http\Request;
16
17 /**
18 * Class Checkout
19 *
20 * @package Packetery
21 */
22 class Checkout {
23
24 public const CARRIER_PREFIX = 'packetery_carrier_';
25 private const NONCE_ACTION = 'packetery_checkout';
26 private const NONCE_NAME = '_wpnonce_packetery_checkout';
27
28 const ATTR_POINT_ID = 'packetery_point_id';
29 const ATTR_POINT_NAME = 'packetery_point_name';
30 const ATTR_POINT_CITY = 'packetery_point_city';
31 const ATTR_POINT_ZIP = 'packetery_point_zip';
32 const ATTR_POINT_STREET = 'packetery_point_street';
33 const ATTR_POINT_PLACE = 'packetery_point_place'; // Business name of pickup point.
34 const ATTR_CARRIER_ID = 'packetery_carrier_id';
35 const ATTR_POINT_URL = 'packetery_point_url';
36
37 const BUTTON_RENDERER_TABLE_ROW = 'table-row';
38 const BUTTON_RENDERER_AFTER_RATE = 'after-rate';
39
40 /**
41 * Tells if hidden fields should be rendered at default place.
42 *
43 * @var bool
44 */
45 private $shouldRenderHiddenFieldsAtDefaultPlace = true;
46
47 /**
48 * Pickup point attributes configuration.
49 *
50 * @var array[]
51 */
52 public static $pickupPointAttrs = array(
53 'id' => array(
54 'name' => self::ATTR_POINT_ID,
55 'required' => true,
56 ),
57 'name' => array(
58 'name' => self::ATTR_POINT_NAME,
59 'required' => true,
60 ),
61 'city' => array(
62 'name' => self::ATTR_POINT_CITY,
63 'required' => true,
64 ),
65 'zip' => array(
66 'name' => self::ATTR_POINT_ZIP,
67 'required' => true,
68 ),
69 'street' => array(
70 'name' => self::ATTR_POINT_STREET,
71 'required' => true,
72 ),
73 'place' => array(
74 'name' => self::ATTR_POINT_PLACE,
75 'required' => false,
76 ),
77 'carrierId' => array(
78 'name' => self::ATTR_CARRIER_ID,
79 'required' => false,
80 ),
81 'url' => array(
82 'name' => self::ATTR_POINT_URL,
83 'required' => true,
84 ),
85 );
86
87 /**
88 * Home delivery attributes configuration.
89 *
90 * @var array[]
91 */
92 private static $homeDeliveryAttrs = [
93 'isValidated' => [
94 'name' => 'packetery_address_isValidated', // Name of checkout hidden form field. Must be unique in entire form.
95 'isWidgetResultField' => false, // Is attribute included in widget result address? By default, it is.
96 ],
97 'houseNumber' => [ // post type address field called 'houseNumber'.
98 'name' => 'packetery_address_houseNumber',
99 ],
100 'street' => [
101 'name' => 'packetery_address_street',
102 ],
103 'city' => [
104 'name' => 'packetery_address_city',
105 ],
106 'postCode' => [
107 'name' => 'packetery_address_postCode',
108 'widgetResultField' => 'postcode', // Widget returns address object containing specified field. By default, it is the array key 'postCode', but in this case it is 'postcode'.
109 ],
110 'county' => [
111 'name' => 'packetery_address_county',
112 ],
113 'country' => [
114 'name' => 'packetery_address_country',
115 ],
116 'latitude' => [
117 'name' => 'packetery_address_latitude',
118 ],
119 'longitude' => [
120 'name' => 'packetery_address_longitude',
121 ],
122 ];
123
124 /**
125 * PacketeryLatte engine
126 *
127 * @var Engine
128 */
129 private $latte_engine;
130
131 /**
132 * Options provider.
133 *
134 * @var Provider Options provider.
135 */
136 private $options_provider;
137
138 /**
139 * Carrier repository.
140 *
141 * @var Carrier\Repository Carrier repository.
142 */
143 private $carrierRepository;
144
145 /**
146 * Http request.
147 *
148 * @var Request Http request.
149 */
150 private $httpRequest;
151
152 /**
153 * Order repository.
154 *
155 * @var Order\Repository
156 */
157 private $orderRepository;
158
159 /**
160 * Currency switcher facade.
161 *
162 * @var CurrencySwitcherFacade
163 */
164 private $currencySwitcherFacade;
165
166 /**
167 * Checkout constructor.
168 *
169 * @param Engine $latte_engine PacketeryLatte engine.
170 * @param Provider $options_provider Options provider.
171 * @param Carrier\Repository $carrierRepository Carrier repository.
172 * @param Request $httpRequest Http request.
173 * @param Order\Repository $orderRepository Order repository.
174 * @param CurrencySwitcherFacade $currencySwitcherFacade Currency switcher facade.
175 */
176 public function __construct(
177 Engine $latte_engine,
178 Provider $options_provider,
179 Carrier\Repository $carrierRepository,
180 Request $httpRequest,
181 Order\Repository $orderRepository,
182 CurrencySwitcherFacade $currencySwitcherFacade
183 ) {
184 $this->latte_engine = $latte_engine;
185 $this->options_provider = $options_provider;
186 $this->carrierRepository = $carrierRepository;
187 $this->httpRequest = $httpRequest;
188 $this->orderRepository = $orderRepository;
189 $this->currencySwitcherFacade = $currencySwitcherFacade;
190 }
191
192 /**
193 * Check if chosen shipping rate is bound with Packeta pickup points
194 *
195 * @return bool
196 */
197 public function isPickupPointOrder(): bool {
198 $chosenMethod = $this->getChosenMethod();
199 $carrierId = $this->getCarrierId( $chosenMethod );
200
201 return $carrierId && $this->carrierRepository->isPickupPointCarrier( $carrierId );
202 }
203
204 /**
205 * Check if chosen shipping rate is bound with Packeta home delivery
206 *
207 * @return bool
208 */
209 public function isHomeDeliveryOrder(): bool {
210 $chosenMethod = $this->getChosenMethod();
211 $carrierId = $this->getCarrierId( $chosenMethod );
212
213 return $carrierId && $this->carrierRepository->isHomeDeliveryCarrier( $carrierId );
214 }
215
216 /**
217 * Render widget button table row.
218 *
219 * @return void
220 */
221 public function renderWidgetButtonTableRow(): void {
222 if ( ! is_checkout() ) {
223 return;
224 }
225
226 if ( $this->shouldRenderHiddenFieldsAtDefaultPlace ) {
227 $this->renderHiddenInputFields();
228 }
229
230 $this->latte_engine->render(
231 PACKETERY_PLUGIN_DIR . '/template/checkout/widget-button-row.latte',
232 [
233 'renderer' => self::BUTTON_RENDERER_TABLE_ROW,
234 'logo' => Plugin::buildAssetUrl( 'public/packeta-symbol.png' ),
235 'translations' => [
236 'packeta' => __( 'Packeta', 'packeta' ),
237 ],
238 ]
239 );
240 }
241
242 /**
243 * Renders widget button and information about chosen pickup point
244 *
245 * @param \WC_Shipping_Rate $shippingRate Shipping rate.
246 */
247 public function renderWidgetButtonAfterShippingRate( \WC_Shipping_Rate $shippingRate ): void {
248 if ( ! is_checkout() ) {
249 return;
250 }
251
252 if ( ! $this->isPacketeryOrder( $shippingRate->get_id() ) ) {
253 return;
254 }
255
256 static $hiddenInputsRendered = false;
257 static $hiddenFieldsRenderedFor = null;
258
259 if ( $this->shouldRenderHiddenFieldsAtDefaultPlace && ( ! $hiddenInputsRendered || $shippingRate->get_id() === $hiddenFieldsRenderedFor ) ) {
260 $this->renderHiddenInputFields();
261 $hiddenInputsRendered = true;
262 $hiddenFieldsRenderedFor = $shippingRate->get_id();
263 }
264
265 $this->latte_engine->render(
266 PACKETERY_PLUGIN_DIR . '/template/checkout/widget-button.latte',
267 [
268 'renderer' => self::BUTTON_RENDERER_AFTER_RATE,
269 'logo' => Plugin::buildAssetUrl( 'public/packeta-symbol.png' ),
270 'translations' => [
271 'packeta' => __( 'Packeta', 'packeta' ),
272 ],
273 ]
274 );
275 }
276
277 /**
278 * Gets widget carriers param.
279 *
280 * @param bool $isPickupPoints Is context pickup point related.
281 * @param string $carrierId Carrier id.
282 *
283 * @return string|null
284 */
285 public static function getWidgetCarriersParam( bool $isPickupPoints, string $carrierId ): ?string {
286 if ( $isPickupPoints ) {
287 return ( is_numeric( $carrierId ) ? $carrierId : Carrier\Repository::INTERNAL_PICKUP_POINTS_ID );
288 }
289
290 return null;
291 }
292
293 /**
294 * Creates settings for checkout script.
295 *
296 * @return array
297 */
298 public function createSettings(): array {
299 $carrierConfig = [];
300 $carriers = $this->carrierRepository->getAllIncludingZpoints();
301
302 foreach ( $carriers as $carrier ) {
303 $optionId = self::CARRIER_PREFIX . $carrier['id'];
304 $carrierConfig[ $optionId ] = [
305 'id' => $carrier['id'],
306 'is_pickup_points' => $carrier['is_pickup_points'],
307 ];
308
309 if ( $carrier['is_pickup_points'] ) {
310 $carrierConfig[ $optionId ]['carriers'] = self::getWidgetCarriersParam( (bool) $carrier['is_pickup_points'], (string) $carrier['id'] );
311 }
312
313 if ( ! $carrier['is_pickup_points'] ) {
314 $carrierOption = get_option( $optionId );
315
316 $addressValidation = 'none';
317 if ( $carrierOption ) {
318 $addressValidation = ( $carrierOption['address_validation'] ?? $addressValidation );
319 }
320
321 $carrierConfig[ $optionId ]['address_validation'] = $addressValidation;
322 }
323 }
324
325 return [
326 'language' => substr( get_locale(), 0, 2 ),
327 'country' => $this->getCustomerCountry(),
328 'weight' => $this->getCartWeightKg(),
329 'carrierConfig' => $carrierConfig,
330 // TODO: Settings are not updated on AJAX checkout update. Needs rework due to possible checkout solutions allowing cart update.
331 'isAgeVerificationRequired' => $this->isAgeVerification18PlusRequired(),
332 'pickupPointAttrs' => self::$pickupPointAttrs,
333 'homeDeliveryAttrs' => self::$homeDeliveryAttrs,
334 'appIdentity' => Plugin::getAppIdentity(),
335 'packeteryApiKey' => $this->options_provider->get_api_key(),
336 'translations' => [
337 'choosePickupPoint' => __( 'Choose pickup point', 'packeta' ),
338 'chooseAddress' => __( 'Check shipping address', 'packeta' ),
339 'addressValidationIsOutOfOrder' => __( 'Address validation is out of order', 'packeta' ),
340 'invalidAddressCountrySelected' => __( 'The selected country does not correspond to the destination country.', 'packeta' ),
341 'selectedShippingAddress' => __( 'Selected shipping address', 'packeta' ),
342 'addressIsValidated' => __( 'Address is validated', 'packeta' ),
343 'addressIsNotValidated' => __( 'Delivery address has not been verified.', 'packeta' ),
344 'addressIsNotValidatedAndRequiredByCarrier' => __( 'Delivery address has not been verified. Verification of delivery address is required by this carrier.', 'packeta' ),
345 ],
346 ];
347 }
348
349 /**
350 * Adds fields to checkout page to save the values later
351 */
352 public function renderHiddenInputFields(): void {
353 $this->latte_engine->render(
354 PACKETERY_PLUGIN_DIR . '/template/checkout/input_fields.latte',
355 [ 'fields' => array_merge( array_column( self::$pickupPointAttrs, 'name' ), array_column( self::$homeDeliveryAttrs, 'name' ) ) ]
356 );
357
358 wp_nonce_field( self::NONCE_ACTION, self::NONCE_NAME );
359 }
360
361 /**
362 * Checks if all pickup point attributes are set, sets an error otherwise.
363 */
364 public function validateCheckoutData(): void {
365 $chosenMethod = $this->getChosenMethod();
366 if ( false === $this->isPacketeryOrder( $chosenMethod ) ) {
367 return;
368 }
369
370 $post = $this->httpRequest->getPost();
371 if ( ! wp_verify_nonce( $post[ self::NONCE_NAME ], self::NONCE_ACTION ) ) {
372 wp_nonce_ays( '' );
373 }
374
375 if ( $this->isPickupPointOrder() ) {
376 $error = false;
377 $required_attrs = array_filter(
378 array_combine(
379 array_column( self::$pickupPointAttrs, 'name' ),
380 array_column( self::$pickupPointAttrs, 'required' )
381 )
382 );
383 foreach ( $required_attrs as $attr => $required ) {
384 $attr_value = null;
385 if ( isset( $post[ $attr ] ) ) {
386 $attr_value = $post[ $attr ];
387 }
388 if ( ! $attr_value ) {
389 $error = true;
390 }
391 }
392 $carrierId = null;
393 if ( isset( $post['carrier_id'] ) ) {
394 $carrierId = $post['carrier_id'];
395 }
396 $pointCarrierId = null;
397 if ( isset( $post['point_carrier_id'] ) ) {
398 $pointCarrierId = $post['point_carrier_id'];
399 }
400 if ( $carrierId && ! $pointCarrierId ) {
401 $error = true;
402 }
403 if ( ! $carrierId && $pointCarrierId ) {
404 $error = true;
405 }
406 if ( $error ) {
407 wc_add_notice( __( 'Pick up point is not chosen.', 'packeta' ), 'error' );
408 }
409 }
410
411 if ( $this->isHomeDeliveryOrder() ) {
412 $carrierId = $this->getCarrierId( $chosenMethod );
413 $optionId = self::CARRIER_PREFIX . $carrierId;
414 $carrierOption = get_option( $optionId );
415
416 $addressValidation = 'none';
417 if ( $carrierOption ) {
418 $addressValidation = ( $carrierOption['address_validation'] ?? $addressValidation );
419 }
420
421 if (
422 'required' === $addressValidation &&
423 (
424 ! isset( $post[ self::$homeDeliveryAttrs['isValidated']['name'] ] ) ||
425 '1' !== $post[ self::$homeDeliveryAttrs['isValidated']['name'] ]
426 )
427 ) {
428 wc_add_notice( __( 'Delivery address has not been verified. Verification of delivery address is required by this carrier.', 'packeta' ), 'error' );
429 }
430 }
431 }
432
433 /**
434 * Saves pickup point and other Packeta information to order.
435 *
436 * @param int $orderId Order id.
437 *
438 * @throws \WC_Data_Exception When invalid data are passed during shipping address update.
439 */
440 public function updateOrderMeta( int $orderId ): void {
441 $chosenMethod = $this->getChosenMethod();
442 if ( false === $this->isPacketeryOrder( $chosenMethod ) ) {
443 return;
444 }
445
446 $post = $this->httpRequest->getPost();
447
448 $propsToSave = [];
449 // Save carrier id for home delivery (we got no id from widget).
450 $carrierId = $this->getCarrierId( $chosenMethod );
451 if ( empty( $post[ self::ATTR_CARRIER_ID ] ) && $carrierId ) {
452 $propsToSave[ self::ATTR_CARRIER_ID ] = $carrierId;
453 }
454
455 if ( $this->isPickupPointOrder() ) {
456 $wcOrder = wc_get_order( $orderId );
457 if ( ! $wcOrder instanceof \WC_Order ) {
458 return;
459 }
460
461 foreach ( self::$pickupPointAttrs as $attr ) {
462 $attrName = $attr['name'];
463 if ( ! isset( $post[ $attrName ] ) ) {
464 continue;
465 }
466 $attrValue = $post[ $attrName ];
467
468 $saveMeta = true;
469 if (
470 ( self::ATTR_CARRIER_ID === $attrName && ! $attrValue ) ||
471 ( self::ATTR_POINT_URL === $attrName && ! filter_var( $attrValue, FILTER_VALIDATE_URL ) )
472 ) {
473 $saveMeta = false;
474 }
475 if ( $saveMeta ) {
476 $propsToSave[ $attrName ] = $attrValue;
477 }
478
479 if ( $this->options_provider->replaceShippingAddressWithPickupPointAddress() ) {
480 self::updateShippingAddressProperty( $wcOrder, $attrName, (string) $attrValue );
481 }
482 }
483 $wcOrder->save();
484 }
485
486 $orderEntity = new Core\Entity\Order( (string) $orderId, $carrierId );
487 if (
488 isset( $post[ self::$homeDeliveryAttrs['isValidated']['name'] ] ) &&
489 '1' === $post[ self::$homeDeliveryAttrs['isValidated']['name'] ] &&
490 $this->isHomeDeliveryOrder()
491 ) {
492 $validatedAddress = new Core\Entity\Address(
493 $post[ self::$homeDeliveryAttrs['street']['name'] ],
494 $post[ self::$homeDeliveryAttrs['city']['name'] ],
495 $post[ self::$homeDeliveryAttrs['postCode']['name'] ]
496 );
497 $validatedAddress->setCounty( $post[ self::$homeDeliveryAttrs['county']['name'] ] );
498 $validatedAddress->setHouseNumber( $post[ self::$homeDeliveryAttrs['houseNumber']['name'] ] );
499 $validatedAddress->setLatitude( $post[ self::$homeDeliveryAttrs['latitude']['name'] ] );
500 $validatedAddress->setLongitude( $post[ self::$homeDeliveryAttrs['longitude']['name'] ] );
501
502 $orderEntity->setDeliveryAddress( $validatedAddress );
503 $orderEntity->setAddressValidated( true );
504 }
505
506 self::updateOrderEntityFromPropsToSave( $orderEntity, $propsToSave );
507 $this->orderRepository->save( $orderEntity );
508 }
509
510 /**
511 * Updates order entity from props to save-
512 *
513 * @param Core\Entity\Order $orderEntity Order entity.
514 * @param array $propsToSave Props to save.
515 *
516 * @return void
517 */
518 public static function updateOrderEntityFromPropsToSave( Core\Entity\Order $orderEntity, array $propsToSave ): void {
519 $orderEntityPickupPoint = $orderEntity->getPickupPoint();
520 if ( null === $orderEntityPickupPoint ) {
521 $orderEntityPickupPoint = new Core\Entity\PickupPoint();
522 }
523
524 foreach ( $propsToSave as $attrName => $attrValue ) {
525 switch ( $attrName ) {
526 case self::ATTR_CARRIER_ID:
527 $orderEntity->setCarrierId( $attrValue );
528 break;
529 case self::ATTR_POINT_ID:
530 $orderEntityPickupPoint->setId( $attrValue );
531 break;
532 case self::ATTR_POINT_NAME:
533 $orderEntityPickupPoint->setName( $attrValue );
534 break;
535 case self::ATTR_POINT_URL:
536 $orderEntityPickupPoint->setUrl( $attrValue );
537 break;
538 case self::ATTR_POINT_STREET:
539 $orderEntityPickupPoint->setStreet( $attrValue );
540 break;
541 case self::ATTR_POINT_ZIP:
542 $orderEntityPickupPoint->setZip( $attrValue );
543 break;
544 case self::ATTR_POINT_CITY:
545 $orderEntityPickupPoint->setCity( $attrValue );
546 break;
547 }
548 }
549
550 $orderEntity->setPickupPoint( $orderEntityPickupPoint );
551 }
552
553 /**
554 * Registers Packeta checkout hooks
555 */
556 public function register_hooks(): void {
557 $activeTheme = strtolower( wp_get_theme()->get_template() );
558 if ( 'divi' === $activeTheme ) {
559 // TODO: Check the possibility to use this placement always.
560 add_action( 'woocommerce_review_order_before_submit', [ $this, 'renderHiddenInputFields' ] );
561 $this->shouldRenderHiddenFieldsAtDefaultPlace = false;
562 }
563
564 add_action( 'woocommerce_checkout_process', array( $this, 'validateCheckoutData' ) );
565 add_action( 'woocommerce_checkout_update_order_meta', array( $this, 'updateOrderMeta' ) );
566 add_action( 'woocommerce_review_order_before_shipping', array( $this, 'updateShippingRates' ), 10, 2 );
567 add_action( 'woocommerce_cart_calculate_fees', [ $this, 'calculateFees' ] );
568 add_action(
569 'init',
570 function () {
571 /**
572 * Tells if widget button table row should be used.
573 *
574 * @since 1.3.0
575 */
576 if ( $this->options_provider->getCheckoutWidgetButtonLocation() === 'after_transport_methods' ) {
577 add_action( 'woocommerce_review_order_after_shipping', [ $this, 'renderWidgetButtonTableRow' ] );
578 } else {
579 add_action( 'woocommerce_after_shipping_rate', [ $this, 'renderWidgetButtonAfterShippingRate' ] );
580 }
581 }
582 );
583 }
584
585 /**
586 * Updates shipping rates cost based on cart properties.
587 * To test, change the shipping price during the transition from the first to the second step of the cart.
588 */
589 public function updateShippingRates(): void {
590 $packages = WC()->shipping()->get_packages();
591 foreach ( $packages as $i => $package ) {
592 WC()->session->set( 'shipping_for_package_' . $i, false );
593 }
594 }
595
596 /**
597 * Gets customer country from WC cart.
598 *
599 * @return string
600 */
601 public function getCustomerCountry(): string {
602 $country = strtolower( WC()->customer->get_shipping_country() );
603 if ( ! $country ) {
604 $country = strtolower( WC()->customer->get_billing_country() );
605 }
606
607 return $country;
608 }
609
610 /**
611 * Gets cart contents weight in kg.
612 *
613 * @return float|int
614 */
615 public function getCartWeightKg() {
616 $weight = WC()->cart->cart_contents_weight;
617 $weightKg = wc_get_weight( $weight, 'kg' );
618 if ( $weightKg ) {
619 $weightKg += $this->options_provider->getPackagingWeight();
620 }
621
622 return $weightKg;
623 }
624
625 /**
626 * Calculates fees.
627 *
628 * @return void
629 */
630 public function calculateFees(): void {
631 $chosenShippingMethod = $this->getChosenMethod();
632 if ( false === $this->isPacketeryOrder( $chosenShippingMethod ) ) {
633 return;
634 }
635
636 $carrierOptions = Carrier\Options::createByOptionId( $chosenShippingMethod );
637 $chosenCarrier = $this->carrierRepository->getAnyById( $this->getExtendedBranchServiceId( $chosenShippingMethod ) );
638 $maxTaxClass = $this->getTaxClassWithMaxRate();
639
640 if (
641 null !== $chosenCarrier &&
642 $chosenCarrier->supportsAgeVerification() &&
643 null !== $carrierOptions->getAgeVerificationFee() &&
644 $this->isAgeVerification18PlusRequired()
645 ) {
646 $feeAmount = $this->currencySwitcherFacade->getConvertedPrice( $carrierOptions->getAgeVerificationFee() );
647 WC()->cart->fees_api()->add_fee(
648 [
649 'id' => 'packetery-age-verification-fee',
650 'name' => __( 'Age verification fee', 'packeta' ),
651 'amount' => $feeAmount,
652 'taxable' => ! ( false === $maxTaxClass ),
653 'tax_class' => $maxTaxClass,
654 ]
655 );
656 }
657
658 $isCod = false;
659 $codPaymentMethod = $this->options_provider->getCodPaymentMethod();
660 $chosenPaymentMethod = WC()->session->get( 'chosen_payment_method' );
661 if ( null !== $codPaymentMethod && ! empty( $chosenPaymentMethod ) && $chosenPaymentMethod === $codPaymentMethod ) {
662 $isCod = true;
663 }
664
665 if ( false === $isCod ) {
666 return;
667 }
668
669 $applicableSurcharge = $this->getCODSurcharge( $carrierOptions->toArray(), $this->getCartPrice() );
670 $applicableSurcharge = $this->currencySwitcherFacade->getConvertedPrice( $applicableSurcharge );
671 if ( 0 >= $applicableSurcharge ) {
672 return;
673 }
674
675 $fee = [
676 'id' => 'packetery-cod-surcharge',
677 'name' => __( 'COD surcharge', 'packeta' ),
678 'amount' => $applicableSurcharge,
679 'taxable' => ! ( false === $maxTaxClass ),
680 'tax_class' => $maxTaxClass,
681 ];
682
683 WC()->cart->fees_api()->add_fee( $fee );
684 }
685
686 /**
687 * Gets cart price. Value is cast to float because PHPDoc is not reliable.
688 *
689 * @return float
690 */
691 private function getCartPrice(): float {
692 return (float) WC()->cart->get_subtotal();
693 }
694
695 /**
696 * Prepare shipping rates based on cart properties.
697 *
698 * @return array
699 */
700 public function getShippingRates(): array {
701 $customerCountry = $this->getCustomerCountry();
702 $availableCarriers = $this->carrierRepository->getByCountryIncludingZpoints( $customerCountry );
703 $carrierOptions = [];
704
705 foreach ( $availableCarriers as $carrier ) {
706 if ( $this->isAgeVerification18PlusRequired() && false === $carrier->supportsAgeVerification() ) {
707 continue;
708 }
709
710 $optionId = self::CARRIER_PREFIX . $carrier->getId();
711 $carrierOptions[ ShippingMethod::PACKETERY_METHOD_ID . ':' . $optionId ] = get_option( $optionId );
712 }
713
714 $cartPrice = $this->getCartContentsTotalIncludingTax();
715
716 $cartWeight = $this->getCartWeightKg();
717 $disallowedShippingRateIds = $this->getDisallowedShippingRateIds();
718
719 $customRates = [];
720 foreach ( $carrierOptions as $optionId => $options ) {
721 if ( ! is_array( $options ) ) {
722 continue;
723 }
724
725 if ( in_array( $optionId, $disallowedShippingRateIds, true ) ) {
726 continue;
727 }
728
729 if ( true === $options['active'] ) {
730 $cost = $this->getRateCost( $options, $cartPrice, $cartWeight );
731 if ( null !== $cost ) {
732 $customRates[ $optionId ] = $this->createShippingRate( $options['name'], $optionId, (float) $cost );
733 }
734 }
735 }
736
737 return $customRates;
738 }
739
740 /**
741 * Gets all possible shipping rates without calculated costs.
742 *
743 * @return iterable
744 */
745 public function getAllShippingRates(): iterable {
746 $countries = $this->carrierRepository->getCountries();
747 foreach ( $countries as $country ) {
748 $availableCarriers = $this->carrierRepository->getByCountryIncludingZpoints( $country );
749 foreach ( $availableCarriers as $carrier ) {
750 $carrierOptions = Carrier\Options::createByCarrierId( $carrier->getId() );
751 if ( $carrierOptions->isActive() ) {
752 yield $this->createShippingRate( $carrierOptions->getName(), $carrierOptions->getOptionId(), null );
753 }
754 }
755 }
756 }
757
758 /**
759 * Computes custom rate cost for carrier using cart contents.
760 *
761 * @param array $carrierOptions Carrier options.
762 * @param float $cartPrice Price.
763 * @param float|int $cartWeight Weight.
764 *
765 * @return ?float
766 */
767 private function getRateCost( array $carrierOptions, float $cartPrice, $cartWeight ) {
768 $cost = null;
769
770 foreach ( $carrierOptions['weight_limits'] as $weightLimit ) {
771 if ( $cartWeight <= $weightLimit['weight'] ) {
772 $cost = $weightLimit['price'];
773 break;
774 }
775 }
776
777 if ( null === $cost ) {
778 return null;
779 }
780
781 if ( $carrierOptions['free_shipping_limit'] ) {
782 $freeShippingLimit = $this->currencySwitcherFacade->getConvertedPrice( $carrierOptions['free_shipping_limit'] );
783 if ( $cartPrice >= $freeShippingLimit ) {
784 $cost = 0;
785 }
786 }
787
788 // WooCommerce currency-switcher.com compatibility.
789 return (float) $cost;
790 }
791
792 /**
793 * Gets applicable COD surcharge.
794 *
795 * @param array $carrierOptions Carrier options.
796 * @param float $cartPrice Cart price.
797 *
798 * @return float
799 */
800 private function getCODSurcharge( array $carrierOptions, float $cartPrice ): float {
801 if ( isset( $carrierOptions['surcharge_limits'] ) ) {
802 foreach ( $carrierOptions['surcharge_limits'] as $weightLimit ) {
803 if ( $cartPrice <= $weightLimit['order_price'] ) {
804 return (float) $weightLimit['surcharge'];
805 }
806 }
807 }
808
809 if ( isset( $carrierOptions['default_COD_surcharge'] ) && is_numeric( $carrierOptions['default_COD_surcharge'] ) ) {
810 return (float) $carrierOptions['default_COD_surcharge'];
811 }
812
813 return 0.0;
814 }
815
816 /**
817 * Get chosen shipping rate id.
818 *
819 * @return string
820 */
821 private function getChosenMethod(): string {
822 $chosenShippingRates = WC()->cart->calculate_shipping();
823 $chosenShippingRate = array_shift( $chosenShippingRates );
824
825 if ( $chosenShippingRate instanceof \WC_Shipping_Rate ) {
826 return $this->getShortenedRateId( $chosenShippingRate->get_id() );
827 }
828
829 return '';
830 }
831
832 /**
833 * Gets carrier id from chosen shipping method.
834 *
835 * @param string $chosenMethod Chosen shipping method.
836 *
837 * @return string|null
838 */
839 public function getCarrierId( string $chosenMethod ): ?string {
840 $branchServiceId = $this->getExtendedBranchServiceId( $chosenMethod );
841 if ( null === $branchServiceId ) {
842 return null;
843 }
844
845 if ( strpos( $branchServiceId, 'zpoint' ) === 0 ) {
846 return Carrier\Repository::INTERNAL_PICKUP_POINTS_ID;
847 }
848
849 return $branchServiceId;
850 }
851
852 /**
853 * Gets feed ID or artificially created ID for internal purposes.
854 *
855 * @param string $chosenMethod Chosen method.
856 *
857 * @return string|null
858 */
859 public function getExtendedBranchServiceId( string $chosenMethod ): ?string {
860 if ( ! $this->isPacketeryOrder( $chosenMethod ) ) {
861 return null;
862 }
863
864 return str_replace( self::CARRIER_PREFIX, '', $chosenMethod );
865 }
866
867 /**
868 * Checks if chosen shipping method is one of packetery.
869 *
870 * @param string $chosenMethod Chosen shipping method.
871 *
872 * @return bool
873 */
874 private function isPacketeryOrder( string $chosenMethod ): bool {
875 $chosenMethod = $this->getShortenedRateId( $chosenMethod );
876 return ( strpos( $chosenMethod, self::CARRIER_PREFIX ) === 0 );
877 }
878
879 /**
880 * Gets ShippingRate's ID of extended id.
881 *
882 * @param string $chosenMethod Chosen shipping method.
883 *
884 * @return string
885 */
886 private function getShortenedRateId( string $chosenMethod ): string {
887 return str_replace( ShippingMethod::PACKETERY_METHOD_ID . ':', '', $chosenMethod );
888 }
889
890 /**
891 * Update order shipping.
892 *
893 * @param \WC_Order $wcOrder WC Order.
894 * @param string $attributeName Attribute name.
895 * @param string $value Value.
896 *
897 * @return void
898 * @throws \WC_Data_Exception When shipping input is invalid.
899 */
900 public static function updateShippingAddressProperty( \WC_Order $wcOrder, string $attributeName, string $value ): void {
901 if ( self::ATTR_POINT_STREET === $attributeName ) {
902 $wcOrder->set_shipping_address_1( $value );
903 $wcOrder->set_shipping_address_2( '' );
904 }
905 if ( self::ATTR_POINT_PLACE === $attributeName ) {
906 $wcOrder->set_shipping_company( $value );
907 }
908 if ( self::ATTR_POINT_CITY === $attributeName ) {
909 $wcOrder->set_shipping_city( $value );
910 }
911 if ( self::ATTR_POINT_ZIP === $attributeName ) {
912 $wcOrder->set_shipping_postcode( $value );
913 }
914 }
915
916 /**
917 * Create shipping rate.
918 *
919 * @param string $name Name.
920 * @param string $optionId Option ID.
921 * @param float|null $cost Cost.
922 *
923 * @return array
924 */
925 public function createShippingRate( string $name, string $optionId, ?float $cost ): array {
926 return [
927 'label' => $name,
928 'id' => $optionId,
929 'cost' => $cost,
930 'taxes' => '',
931 'calc_tax' => 'per_order',
932 ];
933 }
934
935 /**
936 * Gets disallowed shipping rate ids.
937 *
938 * @return array
939 */
940 private function getDisallowedShippingRateIds(): array {
941 $cartProducts = WC()->cart->get_cart();
942
943 $arraysToMerge = [];
944 foreach ( $cartProducts as $cartProduct ) {
945 $productEntity = Product\Entity::fromPostId( $cartProduct['product_id'] );
946
947 if ( false === $productEntity->isPhysical() ) {
948 continue;
949 }
950
951 $arraysToMerge[] = $productEntity->getDisallowedShippingRateIds();
952 }
953
954 return array_unique( array_merge( [], ...$arraysToMerge ) );
955 }
956
957 /**
958 * Tells if age verification is required by products in cart.
959 *
960 * @return bool
961 */
962 private function isAgeVerification18PlusRequired(): bool {
963 $products = WC()->cart->get_cart();
964
965 foreach ( $products as $product ) {
966 $productEntity = Product\Entity::fromPostId( $product['product_id'] );
967 if ( $productEntity->isPhysical() && $productEntity->isAgeVerification18PlusRequired() ) {
968 return true;
969 }
970 }
971
972 return false;
973 }
974
975 /**
976 * Returns tax_class with the highest tax_rate of cart products, false if no product is taxable.
977 *
978 * @return false|string
979 */
980 private function getTaxClassWithMaxRate() {
981 $products = WC()->cart->get_cart();
982 $taxClasses = [];
983
984 foreach ( $products as $cartProduct ) {
985 $product = WC()->product_factory->get_product( $cartProduct['product_id'] );
986 if ( $product->is_taxable() ) {
987 $taxClasses[] = $product->get_tax_class();
988 }
989 }
990
991 if ( empty( $taxClasses ) ) {
992 return false;
993 }
994
995 $taxClasses = array_unique( $taxClasses );
996 if ( 1 === count( $taxClasses ) ) {
997 return $taxClasses[0];
998 }
999
1000 $taxRates = [];
1001 $customer = WC()->cart->get_customer();
1002 foreach ( $taxClasses as $taxClass ) {
1003 $taxRates[ $taxClass ] = \WC_Tax::get_rates( $taxClass, $customer );
1004 }
1005
1006 $maxRate = 0;
1007 $resultTaxClass = false;
1008 foreach ( $taxRates as $taxClassName => $taxClassRates ) {
1009 foreach ( $taxClassRates as $rateId => $rate ) {
1010 if ( $rate['rate'] > $maxRate ) {
1011 $maxRate = $rate['rate'];
1012 $resultTaxClass = $taxClassName;
1013 }
1014 }
1015 }
1016
1017 return $resultTaxClass;
1018 }
1019
1020 /**
1021 * Tells cart contents total price including tax and discounts.
1022 *
1023 * @return float
1024 */
1025 private function getCartContentsTotalIncludingTax():float {
1026 return (float) WC()->cart->get_cart_contents_total() + (float) WC()->cart->get_cart_contents_tax();
1027 }
1028 }
1029