PluginProbe
Packeta / 1.4.1
Packeta v1.4.1
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.1, at src/Packetery/Module/Checkout.php

1,018 lines 28.9 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 $disallowedShippingRateIds = $this->getDisallowedShippingRateIds();
703 $availableCarriers = $this->carrierRepository->getByCountryIncludingZpoints( $customerCountry );
704 $carrierOptions = [];
705
706 foreach ( $availableCarriers as $carrier ) {
707 if ( $this->isAgeVerification18PlusRequired() && false === $carrier->supportsAgeVerification() ) {
708 continue;
709 }
710
711 $optionId = self::CARRIER_PREFIX . $carrier->getId();
712
713 if ( in_array( $optionId, $disallowedShippingRateIds, true ) ) {
714 continue;
715 }
716
717 $carrierOptions[ ShippingMethod::PACKETERY_METHOD_ID . ':' . $optionId ] = get_option( $optionId );
718 }
719
720 $cartPrice = $this->getCartContentsTotalIncludingTax();
721 $cartWeight = $this->getCartWeightKg();
722 $customRates = [];
723
724 foreach ( $carrierOptions as $optionId => $options ) {
725 if ( ! is_array( $options ) ) {
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 * Computes custom rate cost for carrier using cart contents.
742 *
743 * @param array $carrierOptions Carrier options.
744 * @param float $cartPrice Price.
745 * @param float|int $cartWeight Weight.
746 *
747 * @return ?float
748 */
749 private function getRateCost( array $carrierOptions, float $cartPrice, $cartWeight ) {
750 $cost = null;
751
752 foreach ( $carrierOptions['weight_limits'] as $weightLimit ) {
753 if ( $cartWeight <= $weightLimit['weight'] ) {
754 $cost = $weightLimit['price'];
755 break;
756 }
757 }
758
759 if ( null === $cost ) {
760 return null;
761 }
762
763 if ( $carrierOptions['free_shipping_limit'] ) {
764 $freeShippingLimit = $this->currencySwitcherFacade->getConvertedPrice( $carrierOptions['free_shipping_limit'] );
765 if ( $cartPrice >= $freeShippingLimit ) {
766 $cost = 0;
767 }
768 }
769
770 // WooCommerce currency-switcher.com compatibility.
771 return (float) $cost;
772 }
773
774 /**
775 * Gets applicable COD surcharge.
776 *
777 * @param array $carrierOptions Carrier options.
778 * @param float $cartPrice Cart price.
779 *
780 * @return float
781 */
782 private function getCODSurcharge( array $carrierOptions, float $cartPrice ): float {
783 if ( isset( $carrierOptions['surcharge_limits'] ) ) {
784 foreach ( $carrierOptions['surcharge_limits'] as $weightLimit ) {
785 if ( $cartPrice <= $weightLimit['order_price'] ) {
786 return (float) $weightLimit['surcharge'];
787 }
788 }
789 }
790
791 if ( isset( $carrierOptions['default_COD_surcharge'] ) && is_numeric( $carrierOptions['default_COD_surcharge'] ) ) {
792 return (float) $carrierOptions['default_COD_surcharge'];
793 }
794
795 return 0.0;
796 }
797
798 /**
799 * Get chosen shipping rate id.
800 *
801 * @return string
802 */
803 private function getChosenMethod(): string {
804 $chosenShippingRates = WC()->cart->calculate_shipping();
805 $chosenShippingRate = array_shift( $chosenShippingRates );
806
807 if ( $chosenShippingRate instanceof \WC_Shipping_Rate ) {
808 return $this->getShortenedRateId( $chosenShippingRate->get_id() );
809 }
810
811 return '';
812 }
813
814 /**
815 * Gets carrier id from chosen shipping method.
816 *
817 * @param string $chosenMethod Chosen shipping method.
818 *
819 * @return string|null
820 */
821 public function getCarrierId( string $chosenMethod ): ?string {
822 $branchServiceId = $this->getExtendedBranchServiceId( $chosenMethod );
823 if ( null === $branchServiceId ) {
824 return null;
825 }
826
827 if ( strpos( $branchServiceId, 'zpoint' ) === 0 ) {
828 return Carrier\Repository::INTERNAL_PICKUP_POINTS_ID;
829 }
830
831 return $branchServiceId;
832 }
833
834 /**
835 * Gets feed ID or artificially created ID for internal purposes.
836 *
837 * @param string $chosenMethod Chosen method.
838 *
839 * @return string|null
840 */
841 public function getExtendedBranchServiceId( string $chosenMethod ): ?string {
842 if ( ! $this->isPacketeryOrder( $chosenMethod ) ) {
843 return null;
844 }
845
846 return str_replace( self::CARRIER_PREFIX, '', $chosenMethod );
847 }
848
849 /**
850 * Checks if chosen shipping method is one of packetery.
851 *
852 * @param string $chosenMethod Chosen shipping method.
853 *
854 * @return bool
855 */
856 private function isPacketeryOrder( string $chosenMethod ): bool {
857 $chosenMethod = $this->getShortenedRateId( $chosenMethod );
858 return ( strpos( $chosenMethod, self::CARRIER_PREFIX ) === 0 );
859 }
860
861 /**
862 * Gets ShippingRate's ID of extended id.
863 *
864 * @param string $chosenMethod Chosen shipping method.
865 *
866 * @return string
867 */
868 private function getShortenedRateId( string $chosenMethod ): string {
869 return str_replace( ShippingMethod::PACKETERY_METHOD_ID . ':', '', $chosenMethod );
870 }
871
872 /**
873 * Update order shipping.
874 *
875 * @param \WC_Order $wcOrder WC Order.
876 * @param string $attributeName Attribute name.
877 * @param string $value Value.
878 *
879 * @return void
880 * @throws \WC_Data_Exception When shipping input is invalid.
881 */
882 public static function updateShippingAddressProperty( \WC_Order $wcOrder, string $attributeName, string $value ): void {
883 if ( self::ATTR_POINT_STREET === $attributeName ) {
884 $wcOrder->set_shipping_address_1( $value );
885 $wcOrder->set_shipping_address_2( '' );
886 }
887 if ( self::ATTR_POINT_PLACE === $attributeName ) {
888 $wcOrder->set_shipping_company( $value );
889 }
890 if ( self::ATTR_POINT_CITY === $attributeName ) {
891 $wcOrder->set_shipping_city( $value );
892 }
893 if ( self::ATTR_POINT_ZIP === $attributeName ) {
894 $wcOrder->set_shipping_postcode( $value );
895 }
896 }
897
898 /**
899 * Create shipping rate.
900 *
901 * @param string $name Name.
902 * @param string $optionId Option ID.
903 * @param float|null $cost Cost.
904 *
905 * @return array
906 */
907 private function createShippingRate( string $name, string $optionId, ?float $cost ): array {
908 /**
909 * Filter shipping rate cost in checkout
910 *
911 * @since 1.4.1
912 */
913 $cost = apply_filters( 'packeta_shipping_price', $cost );
914
915 return [
916 'label' => $name,
917 'id' => $optionId,
918 'cost' => $cost,
919 'taxes' => '',
920 'calc_tax' => 'per_order',
921 ];
922 }
923
924 /**
925 * Gets disallowed shipping rate ids.
926 *
927 * @return array
928 */
929 private function getDisallowedShippingRateIds(): array {
930 $cartProducts = WC()->cart->get_cart();
931
932 $arraysToMerge = [];
933 foreach ( $cartProducts as $cartProduct ) {
934 $productEntity = Product\Entity::fromPostId( $cartProduct['product_id'] );
935
936 if ( false === $productEntity->isPhysical() ) {
937 continue;
938 }
939
940 $arraysToMerge[] = $productEntity->getDisallowedShippingRateIds();
941 }
942
943 return array_unique( array_merge( [], ...$arraysToMerge ) );
944 }
945
946 /**
947 * Tells if age verification is required by products in cart.
948 *
949 * @return bool
950 */
951 private function isAgeVerification18PlusRequired(): bool {
952 $products = WC()->cart->get_cart();
953
954 foreach ( $products as $product ) {
955 $productEntity = Product\Entity::fromPostId( $product['product_id'] );
956 if ( $productEntity->isPhysical() && $productEntity->isAgeVerification18PlusRequired() ) {
957 return true;
958 }
959 }
960
961 return false;
962 }
963
964 /**
965 * Returns tax_class with the highest tax_rate of cart products, false if no product is taxable.
966 *
967 * @return false|string
968 */
969 private function getTaxClassWithMaxRate() {
970 $products = WC()->cart->get_cart();
971 $taxClasses = [];
972
973 foreach ( $products as $cartProduct ) {
974 $product = WC()->product_factory->get_product( $cartProduct['product_id'] );
975 if ( $product->is_taxable() ) {
976 $taxClasses[] = $product->get_tax_class();
977 }
978 }
979
980 if ( empty( $taxClasses ) ) {
981 return false;
982 }
983
984 $taxClasses = array_unique( $taxClasses );
985 if ( 1 === count( $taxClasses ) ) {
986 return $taxClasses[0];
987 }
988
989 $taxRates = [];
990 $customer = WC()->cart->get_customer();
991 foreach ( $taxClasses as $taxClass ) {
992 $taxRates[ $taxClass ] = \WC_Tax::get_rates( $taxClass, $customer );
993 }
994
995 $maxRate = 0;
996 $resultTaxClass = false;
997 foreach ( $taxRates as $taxClassName => $taxClassRates ) {
998 foreach ( $taxClassRates as $rateId => $rate ) {
999 if ( $rate['rate'] > $maxRate ) {
1000 $maxRate = $rate['rate'];
1001 $resultTaxClass = $taxClassName;
1002 }
1003 }
1004 }
1005
1006 return $resultTaxClass;
1007 }
1008
1009 /**
1010 * Tells cart contents total price including tax and discounts.
1011 *
1012 * @return float
1013 */
1014 private function getCartContentsTotalIncludingTax():float {
1015 return (float) WC()->cart->get_cart_contents_total() + (float) WC()->cart->get_cart_contents_tax();
1016 }
1017 }
1018