PluginProbe
Packeta / 1.4.2
Packeta v1.4.2
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.2, at src/Packetery/Module/Checkout.php

1,023 lines 29.0 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' => false,
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 /**
327 * Filter widget language in checkout.
328 *
329 * @since 1.4.2
330 */
331 'language' => (string) apply_filters( 'packeta_widget_language', substr( get_locale(), 0, 2 ) ),
332 'country' => $this->getCustomerCountry(),
333 'weight' => $this->getCartWeightKg(),
334 'carrierConfig' => $carrierConfig,
335 // TODO: Settings are not updated on AJAX checkout update. Needs rework due to possible checkout solutions allowing cart update.
336 'isAgeVerificationRequired' => $this->isAgeVerification18PlusRequired(),
337 'pickupPointAttrs' => self::$pickupPointAttrs,
338 'homeDeliveryAttrs' => self::$homeDeliveryAttrs,
339 'appIdentity' => Plugin::getAppIdentity(),
340 'packeteryApiKey' => $this->options_provider->get_api_key(),
341 'translations' => [
342 'choosePickupPoint' => __( 'Choose pickup point', 'packeta' ),
343 'chooseAddress' => __( 'Check shipping address', 'packeta' ),
344 'addressValidationIsOutOfOrder' => __( 'Address validation is out of order', 'packeta' ),
345 'invalidAddressCountrySelected' => __( 'The selected country does not correspond to the destination country.', 'packeta' ),
346 'selectedShippingAddress' => __( 'Selected shipping address', 'packeta' ),
347 'addressIsValidated' => __( 'Address is validated', 'packeta' ),
348 'addressIsNotValidated' => __( 'Delivery address has not been verified.', 'packeta' ),
349 'addressIsNotValidatedAndRequiredByCarrier' => __( 'Delivery address has not been verified. Verification of delivery address is required by this carrier.', 'packeta' ),
350 ],
351 ];
352 }
353
354 /**
355 * Adds fields to checkout page to save the values later
356 */
357 public function renderHiddenInputFields(): void {
358 $this->latte_engine->render(
359 PACKETERY_PLUGIN_DIR . '/template/checkout/input_fields.latte',
360 [ 'fields' => array_merge( array_column( self::$pickupPointAttrs, 'name' ), array_column( self::$homeDeliveryAttrs, 'name' ) ) ]
361 );
362
363 wp_nonce_field( self::NONCE_ACTION, self::NONCE_NAME );
364 }
365
366 /**
367 * Checks if all pickup point attributes are set, sets an error otherwise.
368 */
369 public function validateCheckoutData(): void {
370 $chosenMethod = $this->getChosenMethod();
371 if ( false === $this->isPacketeryOrder( $chosenMethod ) ) {
372 return;
373 }
374
375 $post = $this->httpRequest->getPost();
376 if ( ! wp_verify_nonce( $post[ self::NONCE_NAME ], self::NONCE_ACTION ) ) {
377 wp_nonce_ays( '' );
378 }
379
380 if ( $this->isPickupPointOrder() ) {
381 $error = false;
382 $required_attrs = array_filter(
383 array_combine(
384 array_column( self::$pickupPointAttrs, 'name' ),
385 array_column( self::$pickupPointAttrs, 'required' )
386 )
387 );
388 foreach ( $required_attrs as $attr => $required ) {
389 $attr_value = null;
390 if ( isset( $post[ $attr ] ) ) {
391 $attr_value = $post[ $attr ];
392 }
393 if ( ! $attr_value ) {
394 $error = true;
395 }
396 }
397 $carrierId = null;
398 if ( isset( $post['carrier_id'] ) ) {
399 $carrierId = $post['carrier_id'];
400 }
401 $pointCarrierId = null;
402 if ( isset( $post['point_carrier_id'] ) ) {
403 $pointCarrierId = $post['point_carrier_id'];
404 }
405 if ( $carrierId && ! $pointCarrierId ) {
406 $error = true;
407 }
408 if ( ! $carrierId && $pointCarrierId ) {
409 $error = true;
410 }
411 if ( $error ) {
412 wc_add_notice( __( 'Pick up point is not chosen.', 'packeta' ), 'error' );
413 }
414 }
415
416 if ( $this->isHomeDeliveryOrder() ) {
417 $carrierId = $this->getCarrierId( $chosenMethod );
418 $optionId = self::CARRIER_PREFIX . $carrierId;
419 $carrierOption = get_option( $optionId );
420
421 $addressValidation = 'none';
422 if ( $carrierOption ) {
423 $addressValidation = ( $carrierOption['address_validation'] ?? $addressValidation );
424 }
425
426 if (
427 'required' === $addressValidation &&
428 (
429 ! isset( $post[ self::$homeDeliveryAttrs['isValidated']['name'] ] ) ||
430 '1' !== $post[ self::$homeDeliveryAttrs['isValidated']['name'] ]
431 )
432 ) {
433 wc_add_notice( __( 'Delivery address has not been verified. Verification of delivery address is required by this carrier.', 'packeta' ), 'error' );
434 }
435 }
436 }
437
438 /**
439 * Saves pickup point and other Packeta information to order.
440 *
441 * @param int $orderId Order id.
442 *
443 * @throws \WC_Data_Exception When invalid data are passed during shipping address update.
444 */
445 public function updateOrderMeta( int $orderId ): void {
446 $chosenMethod = $this->getChosenMethod();
447 if ( false === $this->isPacketeryOrder( $chosenMethod ) ) {
448 return;
449 }
450
451 $post = $this->httpRequest->getPost();
452
453 $propsToSave = [];
454 // Save carrier id for home delivery (we got no id from widget).
455 $carrierId = $this->getCarrierId( $chosenMethod );
456 if ( empty( $post[ self::ATTR_CARRIER_ID ] ) && $carrierId ) {
457 $propsToSave[ self::ATTR_CARRIER_ID ] = $carrierId;
458 }
459
460 if ( $this->isPickupPointOrder() ) {
461 $wcOrder = wc_get_order( $orderId );
462 if ( ! $wcOrder instanceof \WC_Order ) {
463 return;
464 }
465
466 foreach ( self::$pickupPointAttrs as $attr ) {
467 $attrName = $attr['name'];
468 if ( ! isset( $post[ $attrName ] ) ) {
469 continue;
470 }
471 $attrValue = $post[ $attrName ];
472
473 $saveMeta = true;
474 if (
475 ( self::ATTR_CARRIER_ID === $attrName && ! $attrValue ) ||
476 ( self::ATTR_POINT_URL === $attrName && ! filter_var( $attrValue, FILTER_VALIDATE_URL ) )
477 ) {
478 $saveMeta = false;
479 }
480 if ( $saveMeta ) {
481 $propsToSave[ $attrName ] = $attrValue;
482 }
483
484 if ( $this->options_provider->replaceShippingAddressWithPickupPointAddress() ) {
485 self::updateShippingAddressProperty( $wcOrder, $attrName, (string) $attrValue );
486 }
487 }
488 $wcOrder->save();
489 }
490
491 $orderEntity = new Core\Entity\Order( (string) $orderId, $carrierId );
492 if (
493 isset( $post[ self::$homeDeliveryAttrs['isValidated']['name'] ] ) &&
494 '1' === $post[ self::$homeDeliveryAttrs['isValidated']['name'] ] &&
495 $this->isHomeDeliveryOrder()
496 ) {
497 $validatedAddress = new Core\Entity\Address(
498 $post[ self::$homeDeliveryAttrs['street']['name'] ],
499 $post[ self::$homeDeliveryAttrs['city']['name'] ],
500 $post[ self::$homeDeliveryAttrs['postCode']['name'] ]
501 );
502 $validatedAddress->setCounty( $post[ self::$homeDeliveryAttrs['county']['name'] ] );
503 $validatedAddress->setHouseNumber( $post[ self::$homeDeliveryAttrs['houseNumber']['name'] ] );
504 $validatedAddress->setLatitude( $post[ self::$homeDeliveryAttrs['latitude']['name'] ] );
505 $validatedAddress->setLongitude( $post[ self::$homeDeliveryAttrs['longitude']['name'] ] );
506
507 $orderEntity->setDeliveryAddress( $validatedAddress );
508 $orderEntity->setAddressValidated( true );
509 }
510
511 self::updateOrderEntityFromPropsToSave( $orderEntity, $propsToSave );
512 $this->orderRepository->save( $orderEntity );
513 }
514
515 /**
516 * Updates order entity from props to save-
517 *
518 * @param Core\Entity\Order $orderEntity Order entity.
519 * @param array $propsToSave Props to save.
520 *
521 * @return void
522 */
523 public static function updateOrderEntityFromPropsToSave( Core\Entity\Order $orderEntity, array $propsToSave ): void {
524 $orderEntityPickupPoint = $orderEntity->getPickupPoint();
525 if ( null === $orderEntityPickupPoint ) {
526 $orderEntityPickupPoint = new Core\Entity\PickupPoint();
527 }
528
529 foreach ( $propsToSave as $attrName => $attrValue ) {
530 switch ( $attrName ) {
531 case self::ATTR_CARRIER_ID:
532 $orderEntity->setCarrierId( $attrValue );
533 break;
534 case self::ATTR_POINT_ID:
535 $orderEntityPickupPoint->setId( $attrValue );
536 break;
537 case self::ATTR_POINT_NAME:
538 $orderEntityPickupPoint->setName( $attrValue );
539 break;
540 case self::ATTR_POINT_URL:
541 $orderEntityPickupPoint->setUrl( $attrValue );
542 break;
543 case self::ATTR_POINT_STREET:
544 $orderEntityPickupPoint->setStreet( $attrValue );
545 break;
546 case self::ATTR_POINT_ZIP:
547 $orderEntityPickupPoint->setZip( $attrValue );
548 break;
549 case self::ATTR_POINT_CITY:
550 $orderEntityPickupPoint->setCity( $attrValue );
551 break;
552 }
553 }
554
555 $orderEntity->setPickupPoint( $orderEntityPickupPoint );
556 }
557
558 /**
559 * Registers Packeta checkout hooks
560 */
561 public function register_hooks(): void {
562 $activeTheme = strtolower( wp_get_theme()->get_template() );
563 if ( 'divi' === $activeTheme ) {
564 // TODO: Check the possibility to use this placement always.
565 add_action( 'woocommerce_review_order_before_submit', [ $this, 'renderHiddenInputFields' ] );
566 $this->shouldRenderHiddenFieldsAtDefaultPlace = false;
567 }
568
569 add_action( 'woocommerce_checkout_process', array( $this, 'validateCheckoutData' ) );
570 add_action( 'woocommerce_checkout_update_order_meta', array( $this, 'updateOrderMeta' ) );
571 add_action( 'woocommerce_review_order_before_shipping', array( $this, 'updateShippingRates' ), 10, 2 );
572 add_action( 'woocommerce_cart_calculate_fees', [ $this, 'calculateFees' ] );
573 add_action(
574 'init',
575 function () {
576 /**
577 * Tells if widget button table row should be used.
578 *
579 * @since 1.3.0
580 */
581 if ( $this->options_provider->getCheckoutWidgetButtonLocation() === 'after_transport_methods' ) {
582 add_action( 'woocommerce_review_order_after_shipping', [ $this, 'renderWidgetButtonTableRow' ] );
583 } else {
584 add_action( 'woocommerce_after_shipping_rate', [ $this, 'renderWidgetButtonAfterShippingRate' ] );
585 }
586 }
587 );
588 }
589
590 /**
591 * Updates shipping rates cost based on cart properties.
592 * To test, change the shipping price during the transition from the first to the second step of the cart.
593 */
594 public function updateShippingRates(): void {
595 $packages = WC()->shipping()->get_packages();
596 foreach ( $packages as $i => $package ) {
597 WC()->session->set( 'shipping_for_package_' . $i, false );
598 }
599 }
600
601 /**
602 * Gets customer country from WC cart.
603 *
604 * @return string
605 */
606 public function getCustomerCountry(): string {
607 $country = strtolower( WC()->customer->get_shipping_country() );
608 if ( ! $country ) {
609 $country = strtolower( WC()->customer->get_billing_country() );
610 }
611
612 return $country;
613 }
614
615 /**
616 * Gets cart contents weight in kg.
617 *
618 * @return float|int
619 */
620 public function getCartWeightKg() {
621 $weight = WC()->cart->cart_contents_weight;
622 $weightKg = wc_get_weight( $weight, 'kg' );
623 if ( $weightKg ) {
624 $weightKg += $this->options_provider->getPackagingWeight();
625 }
626
627 return $weightKg;
628 }
629
630 /**
631 * Calculates fees.
632 *
633 * @return void
634 */
635 public function calculateFees(): void {
636 $chosenShippingMethod = $this->getChosenMethod();
637 if ( false === $this->isPacketeryOrder( $chosenShippingMethod ) ) {
638 return;
639 }
640
641 $carrierOptions = Carrier\Options::createByOptionId( $chosenShippingMethod );
642 $chosenCarrier = $this->carrierRepository->getAnyById( $this->getExtendedBranchServiceId( $chosenShippingMethod ) );
643 $maxTaxClass = $this->getTaxClassWithMaxRate();
644
645 if (
646 null !== $chosenCarrier &&
647 $chosenCarrier->supportsAgeVerification() &&
648 null !== $carrierOptions->getAgeVerificationFee() &&
649 $this->isAgeVerification18PlusRequired()
650 ) {
651 $feeAmount = $this->currencySwitcherFacade->getConvertedPrice( $carrierOptions->getAgeVerificationFee() );
652 WC()->cart->fees_api()->add_fee(
653 [
654 'id' => 'packetery-age-verification-fee',
655 'name' => __( 'Age verification fee', 'packeta' ),
656 'amount' => $feeAmount,
657 'taxable' => ! ( false === $maxTaxClass ),
658 'tax_class' => $maxTaxClass,
659 ]
660 );
661 }
662
663 $isCod = false;
664 $codPaymentMethod = $this->options_provider->getCodPaymentMethod();
665 $chosenPaymentMethod = WC()->session->get( 'chosen_payment_method' );
666 if ( null !== $codPaymentMethod && ! empty( $chosenPaymentMethod ) && $chosenPaymentMethod === $codPaymentMethod ) {
667 $isCod = true;
668 }
669
670 if ( false === $isCod ) {
671 return;
672 }
673
674 $applicableSurcharge = $this->getCODSurcharge( $carrierOptions->toArray(), $this->getCartPrice() );
675 $applicableSurcharge = $this->currencySwitcherFacade->getConvertedPrice( $applicableSurcharge );
676 if ( 0 >= $applicableSurcharge ) {
677 return;
678 }
679
680 $fee = [
681 'id' => 'packetery-cod-surcharge',
682 'name' => __( 'COD surcharge', 'packeta' ),
683 'amount' => $applicableSurcharge,
684 'taxable' => ! ( false === $maxTaxClass ),
685 'tax_class' => $maxTaxClass,
686 ];
687
688 WC()->cart->fees_api()->add_fee( $fee );
689 }
690
691 /**
692 * Gets cart price. Value is cast to float because PHPDoc is not reliable.
693 *
694 * @return float
695 */
696 private function getCartPrice(): float {
697 return (float) WC()->cart->get_subtotal();
698 }
699
700 /**
701 * Prepare shipping rates based on cart properties.
702 *
703 * @return array
704 */
705 public function getShippingRates(): array {
706 $customerCountry = $this->getCustomerCountry();
707 $disallowedShippingRateIds = $this->getDisallowedShippingRateIds();
708 $availableCarriers = $this->carrierRepository->getByCountryIncludingZpoints( $customerCountry );
709 $carrierOptions = [];
710
711 foreach ( $availableCarriers as $carrier ) {
712 if ( $this->isAgeVerification18PlusRequired() && false === $carrier->supportsAgeVerification() ) {
713 continue;
714 }
715
716 $optionId = self::CARRIER_PREFIX . $carrier->getId();
717
718 if ( in_array( $optionId, $disallowedShippingRateIds, true ) ) {
719 continue;
720 }
721
722 $carrierOptions[ ShippingMethod::PACKETERY_METHOD_ID . ':' . $optionId ] = get_option( $optionId );
723 }
724
725 $cartPrice = $this->getCartContentsTotalIncludingTax();
726 $cartWeight = $this->getCartWeightKg();
727 $customRates = [];
728
729 foreach ( $carrierOptions as $optionId => $options ) {
730 if ( ! is_array( $options ) ) {
731 continue;
732 }
733
734 if ( true === $options['active'] ) {
735 $cost = $this->getRateCost( $options, $cartPrice, $cartWeight );
736 if ( null !== $cost ) {
737 $customRates[ $optionId ] = $this->createShippingRate( $options['name'], $optionId, (float) $cost );
738 }
739 }
740 }
741
742 return $customRates;
743 }
744
745 /**
746 * Computes custom rate cost for carrier using cart contents.
747 *
748 * @param array $carrierOptions Carrier options.
749 * @param float $cartPrice Price.
750 * @param float|int $cartWeight Weight.
751 *
752 * @return ?float
753 */
754 private function getRateCost( array $carrierOptions, float $cartPrice, $cartWeight ) {
755 $cost = null;
756
757 foreach ( $carrierOptions['weight_limits'] as $weightLimit ) {
758 if ( $cartWeight <= $weightLimit['weight'] ) {
759 $cost = $weightLimit['price'];
760 break;
761 }
762 }
763
764 if ( null === $cost ) {
765 return null;
766 }
767
768 if ( $carrierOptions['free_shipping_limit'] ) {
769 $freeShippingLimit = $this->currencySwitcherFacade->getConvertedPrice( $carrierOptions['free_shipping_limit'] );
770 if ( $cartPrice >= $freeShippingLimit ) {
771 $cost = 0;
772 }
773 }
774
775 // WooCommerce currency-switcher.com compatibility.
776 return (float) $cost;
777 }
778
779 /**
780 * Gets applicable COD surcharge.
781 *
782 * @param array $carrierOptions Carrier options.
783 * @param float $cartPrice Cart price.
784 *
785 * @return float
786 */
787 private function getCODSurcharge( array $carrierOptions, float $cartPrice ): float {
788 if ( isset( $carrierOptions['surcharge_limits'] ) ) {
789 foreach ( $carrierOptions['surcharge_limits'] as $weightLimit ) {
790 if ( $cartPrice <= $weightLimit['order_price'] ) {
791 return (float) $weightLimit['surcharge'];
792 }
793 }
794 }
795
796 if ( isset( $carrierOptions['default_COD_surcharge'] ) && is_numeric( $carrierOptions['default_COD_surcharge'] ) ) {
797 return (float) $carrierOptions['default_COD_surcharge'];
798 }
799
800 return 0.0;
801 }
802
803 /**
804 * Get chosen shipping rate id.
805 *
806 * @return string
807 */
808 private function getChosenMethod(): string {
809 $chosenShippingRates = WC()->cart->calculate_shipping();
810 $chosenShippingRate = array_shift( $chosenShippingRates );
811
812 if ( $chosenShippingRate instanceof \WC_Shipping_Rate ) {
813 return $this->getShortenedRateId( $chosenShippingRate->get_id() );
814 }
815
816 return '';
817 }
818
819 /**
820 * Gets carrier id from chosen shipping method.
821 *
822 * @param string $chosenMethod Chosen shipping method.
823 *
824 * @return string|null
825 */
826 public function getCarrierId( string $chosenMethod ): ?string {
827 $branchServiceId = $this->getExtendedBranchServiceId( $chosenMethod );
828 if ( null === $branchServiceId ) {
829 return null;
830 }
831
832 if ( strpos( $branchServiceId, 'zpoint' ) === 0 ) {
833 return Carrier\Repository::INTERNAL_PICKUP_POINTS_ID;
834 }
835
836 return $branchServiceId;
837 }
838
839 /**
840 * Gets feed ID or artificially created ID for internal purposes.
841 *
842 * @param string $chosenMethod Chosen method.
843 *
844 * @return string|null
845 */
846 public function getExtendedBranchServiceId( string $chosenMethod ): ?string {
847 if ( ! $this->isPacketeryOrder( $chosenMethod ) ) {
848 return null;
849 }
850
851 return str_replace( self::CARRIER_PREFIX, '', $chosenMethod );
852 }
853
854 /**
855 * Checks if chosen shipping method is one of packetery.
856 *
857 * @param string $chosenMethod Chosen shipping method.
858 *
859 * @return bool
860 */
861 private function isPacketeryOrder( string $chosenMethod ): bool {
862 $chosenMethod = $this->getShortenedRateId( $chosenMethod );
863 return ( strpos( $chosenMethod, self::CARRIER_PREFIX ) === 0 );
864 }
865
866 /**
867 * Gets ShippingRate's ID of extended id.
868 *
869 * @param string $chosenMethod Chosen shipping method.
870 *
871 * @return string
872 */
873 private function getShortenedRateId( string $chosenMethod ): string {
874 return str_replace( ShippingMethod::PACKETERY_METHOD_ID . ':', '', $chosenMethod );
875 }
876
877 /**
878 * Update order shipping.
879 *
880 * @param \WC_Order $wcOrder WC Order.
881 * @param string $attributeName Attribute name.
882 * @param string $value Value.
883 *
884 * @return void
885 * @throws \WC_Data_Exception When shipping input is invalid.
886 */
887 public static function updateShippingAddressProperty( \WC_Order $wcOrder, string $attributeName, string $value ): void {
888 if ( self::ATTR_POINT_STREET === $attributeName ) {
889 $wcOrder->set_shipping_address_1( $value );
890 $wcOrder->set_shipping_address_2( '' );
891 }
892 if ( self::ATTR_POINT_PLACE === $attributeName ) {
893 $wcOrder->set_shipping_company( $value );
894 }
895 if ( self::ATTR_POINT_CITY === $attributeName ) {
896 $wcOrder->set_shipping_city( $value );
897 }
898 if ( self::ATTR_POINT_ZIP === $attributeName ) {
899 $wcOrder->set_shipping_postcode( $value );
900 }
901 }
902
903 /**
904 * Create shipping rate.
905 *
906 * @param string $name Name.
907 * @param string $optionId Option ID.
908 * @param float|null $cost Cost.
909 *
910 * @return array
911 */
912 private function createShippingRate( string $name, string $optionId, ?float $cost ): array {
913 /**
914 * Filter shipping rate cost in checkout
915 *
916 * @since 1.4.1
917 */
918 $cost = apply_filters( 'packeta_shipping_price', $cost );
919
920 return [
921 'label' => $name,
922 'id' => $optionId,
923 'cost' => $cost,
924 'taxes' => '',
925 'calc_tax' => 'per_order',
926 ];
927 }
928
929 /**
930 * Gets disallowed shipping rate ids.
931 *
932 * @return array
933 */
934 private function getDisallowedShippingRateIds(): array {
935 $cartProducts = WC()->cart->get_cart();
936
937 $arraysToMerge = [];
938 foreach ( $cartProducts as $cartProduct ) {
939 $productEntity = Product\Entity::fromPostId( $cartProduct['product_id'] );
940
941 if ( false === $productEntity->isPhysical() ) {
942 continue;
943 }
944
945 $arraysToMerge[] = $productEntity->getDisallowedShippingRateIds();
946 }
947
948 return array_unique( array_merge( [], ...$arraysToMerge ) );
949 }
950
951 /**
952 * Tells if age verification is required by products in cart.
953 *
954 * @return bool
955 */
956 private function isAgeVerification18PlusRequired(): bool {
957 $products = WC()->cart->get_cart();
958
959 foreach ( $products as $product ) {
960 $productEntity = Product\Entity::fromPostId( $product['product_id'] );
961 if ( $productEntity->isPhysical() && $productEntity->isAgeVerification18PlusRequired() ) {
962 return true;
963 }
964 }
965
966 return false;
967 }
968
969 /**
970 * Returns tax_class with the highest tax_rate of cart products, false if no product is taxable.
971 *
972 * @return false|string
973 */
974 private function getTaxClassWithMaxRate() {
975 $products = WC()->cart->get_cart();
976 $taxClasses = [];
977
978 foreach ( $products as $cartProduct ) {
979 $product = WC()->product_factory->get_product( $cartProduct['product_id'] );
980 if ( $product->is_taxable() ) {
981 $taxClasses[] = $product->get_tax_class();
982 }
983 }
984
985 if ( empty( $taxClasses ) ) {
986 return false;
987 }
988
989 $taxClasses = array_unique( $taxClasses );
990 if ( 1 === count( $taxClasses ) ) {
991 return $taxClasses[0];
992 }
993
994 $taxRates = [];
995 $customer = WC()->cart->get_customer();
996 foreach ( $taxClasses as $taxClass ) {
997 $taxRates[ $taxClass ] = \WC_Tax::get_rates( $taxClass, $customer );
998 }
999
1000 $maxRate = 0;
1001 $resultTaxClass = false;
1002 foreach ( $taxRates as $taxClassName => $taxClassRates ) {
1003 foreach ( $taxClassRates as $rateId => $rate ) {
1004 if ( $rate['rate'] > $maxRate ) {
1005 $maxRate = $rate['rate'];
1006 $resultTaxClass = $taxClassName;
1007 }
1008 }
1009 }
1010
1011 return $resultTaxClass;
1012 }
1013
1014 /**
1015 * Tells cart contents total price including tax and discounts.
1016 *
1017 * @return float
1018 */
1019 private function getCartContentsTotalIncludingTax():float {
1020 return (float) WC()->cart->get_cart_contents_total() + (float) WC()->cart->get_cart_contents_tax();
1021 }
1022 }
1023