PluginProbe
PostNL for WooCommerce / trunk
PostNL for WooCommerce vtrunk
5.9.11 5.9.10 5.9.9 5.9.8 5.9.7 5.9.6 trunk 2.5.0 2.5.1 2.5.2 2.5.3 2.5.4 2.5.5 3.1.4 3.1.5 3.1.6 3.1.7 4.0.0 4.0.1 4.0.2 4.3.2 4.3.3 4.4.0 4.4.1 4.4.2 All 71 releases
woo-postnl / src / Frontend / Container.php

Container.php in PostNL for WooCommerce trunk, at src/Frontend/Container.php

966 lines 33.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Class Frontend/Container file.
4 *
5 * @package PostNLWooCommerce\Frontend
6 */
7
8 namespace PostNLWooCommerce\Frontend;
9
10 use PostNLWooCommerce\Address_Utils;
11 use PostNLWooCommerce\Shipping_Method\Settings;
12 use PostNLWooCommerce\Rest_API\Service_Factory;
13 use PostNLWooCommerce\Rest_API\Contracts\Pickup_Location_Service_Interface;
14 use PostNLWooCommerce\Rest_API\Contracts\Timeframe_Service_Interface;
15 use PostNLWooCommerce\Utils;
16 use PostNLWooCommerce\Helper\Mapping;
17 use PostNLWooCommerce\Frontend\Checkout_Fields;
18
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit;
21 }
22
23 /**
24 * Class Delivery_Day
25 *
26 * @package PostNLWooCommerce\Frontend
27 */
28 class Container {
29 /**
30 * Settings class instance.
31 *
32 * @var PostNLWooCommerce\Shipping_Method\Settings
33 */
34 protected $settings;
35
36 /**
37 * Lazy-initialised Service_Factory instance.
38 *
39 * @var Service_Factory|null
40 */
41 private $service_factory_instance = null;
42
43 /**
44 * Tab field name.
45 *
46 * @var tab_field
47 */
48 protected $tab_field = POSTNL_SETTINGS_ID . '_option';
49
50 /**
51 * Init and hook in the integration.
52 *
53 * @param bool $register_hooks Whether to register the WordPress hooks. The
54 * bootstrap instance (Main::get_frontend()) passes
55 * true; transient instances created only to reuse
56 * helper methods (e.g. the blocks AJAX handler) must
57 * pass false, otherwise the global woocommerce_package_rates
58 * filters get registered twice and inject_letterbox_rates_for_all_methods
59 * runs twice in the same request, duplicating the 24h/48h rates.
60 */
61 public function __construct( bool $register_hooks = true ) {
62 $this->settings = Settings::get_instance();
63
64 if ( $register_hooks ) {
65 $this->init_hooks();
66 }
67 }
68
69 /**
70 * Collection of hooks when initiation.
71 */
72 public function init_hooks() {
73 add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts_styles' ) );
74
75 add_action( 'woocommerce_review_order_after_shipping', array( $this, 'postnl_fields' ), 10 );
76 add_action( 'woocommerce_cart_calculate_fees', array( $this, 'add_cart_fees' ), 10, 1 );
77
78 add_filter( 'woocommerce_update_order_review_fragments', array( $this, 'fill_validated_address' ) );
79 add_filter( 'woocommerce_cart_shipping_method_full_label', array( $this, 'add_shipping_method_icon' ), 10, 2 );
80
81 if ( ! Utils::is_blocks_checkout() ) {
82 add_filter( 'woocommerce_package_rates', array( $this, 'inject_postnl_base_fees' ), 20, 2 );
83 }
84 add_filter( 'woocommerce_package_rates', array( $this, 'inject_letterbox_rates_for_all_methods' ), 15, 2 );
85 add_filter( 'woocommerce_cart_shipping_packages', array( $this, 'add_postnl_option_to_package' ) );
86 }
87
88 /**
89 * Return a lazy-initialised Service_Factory instance.
90 *
91 * @return Service_Factory
92 */
93 private function service_factory(): Service_Factory {
94 if ( null === $this->service_factory_instance ) {
95 $this->service_factory_instance = new Service_Factory( $this->settings );
96 }
97 return $this->service_factory_instance;
98 }
99
100 /**
101 * Enqueue scripts and style.
102 */
103 public function enqueue_scripts_styles() {
104 if ( ! is_checkout() && ! is_cart() ) {
105 return;
106 }
107
108 // Enqueue styles.
109 wp_enqueue_style(
110 'postnl-fe-checkout',
111 POSTNL_WC_PLUGIN_DIR_URL . '/assets/css/fe-checkout.css',
112 array( 'postnl-fill-in-button' ),
113 POSTNL_WC_VERSION
114 );
115
116 // Only enqueue JS for classic checkout.
117 if ( Utils::is_blocks_checkout() ) {
118 return;
119 }
120
121 if ( is_cart() ) {
122 return;
123 }
124
125 wp_enqueue_script(
126 'postnl-fe-checkout',
127 POSTNL_WC_PLUGIN_DIR_URL . '/assets/js/fe-checkout.js',
128 array( 'jquery' ),
129 POSTNL_WC_VERSION,
130 true
131 );
132
133 $settings = Settings::get_instance();
134
135 wp_localize_script(
136 'postnl-fe-checkout',
137 'postnlParams',
138 array(
139 'i18n' => array(
140 'deliveryDays' => esc_html__( 'Delivery Days', 'postnl-for-woocommerce' ),
141 'pickup' => esc_html__( 'Pickup', 'postnl-for-woocommerce' ),
142 ),
143 'delivery_day_fee_formatted' => Utils::get_formatted_fee_total_price( $settings->get_delivery_days_fee() ),
144 'pickup_fee_formatted' => Utils::get_formatted_fee_total_price( $settings->get_pickup_delivery_fee() ),
145 'currency' => array(
146 'symbol' => html_entity_decode( get_woocommerce_currency_symbol(), ENT_QUOTES, 'UTF-8' ),
147 'symbolPosition' => get_option( 'woocommerce_currency_pos', 'left' ),
148 'decimalSeparator' => wc_get_price_decimal_separator(),
149 'thousandSeparator' => wc_get_price_thousand_separator(),
150 'precision' => wc_get_price_decimals(),
151 ),
152 )
153 );
154 }
155
156 /**
157 * Get enabled tabs.
158 *
159 * @param array $response Response from PostNL Checkout Rest API.
160 *
161 * @return array
162 */
163 public function get_available_tabs( $response ) {
164 return apply_filters( 'postnl_frontend_checkout_tab', array(), $response );
165 }
166
167 /**
168 * Get tab field value.
169 *
170 * @param array $post_data Array of global _POST data.
171 *
172 * @return String
173 */
174 public function get_tab_field_value( $post_data ) {
175 return ( ! empty( $post_data[ $this->tab_field ] ) ) ? $post_data[ $this->tab_field ] : '';
176 }
177
178 /**
179 * Get checkout $_POST['post_data'].
180 *
181 * @return array
182 */
183 public function get_checkout_post_data() {
184 if ( empty( $_REQUEST['post_data'] ) ) {
185 return array();
186 }
187
188 $post_data = array();
189
190 parse_str( sanitize_text_field( wp_unslash( urldecode( $_REQUEST['post_data'] ) ) ), $post_data );
191
192 return $post_data;
193 }
194
195 /**
196 * Get data from PostNL Checkout Rest API.
197 *
198 * @param array $post_data Checkout post input.
199 *
200 * @return array.
201 * @throws \Exception If the checkout data process has error.
202 */
203 public function get_checkout_data( $post_data ) {
204 $response = self::aggregate_delivery_options(
205 $this->service_factory()->timeframe_service(),
206 $this->service_factory()->pickup_location_service(),
207 $post_data,
208 $this->settings->is_pickup_points_enabled()
209 );
210 $letterbox = Utils::is_cart_eligible_auto_letterbox( \WC()->cart );
211
212 return array(
213 'response' => $response,
214 'post_data' => $post_data,
215 'letterbox' => $letterbox,
216 );
217 }
218
219 /**
220 * Compose the checkout response from the delivery-day and pickup-location services.
221 *
222 * V1 returned delivery days and pickup points in a single /shipment/v1/checkout
223 * response, so the Legacy transport resolves both flows to one shared
224 * Checkout_Service instance whose one response already carries DeliveryOptions
225 * and PickupOptions. V4 splits them into two endpoints, so Service_Factory hands
226 * back a distinct pickup service that must be queried separately and its result
227 * merged back into the delivery-day response.
228 *
229 * The two transports are reconciled here, at the consumer, so the assembled
230 * array is identical either way and Frontend\Delivery_Day / Frontend\Dropoff_Points
231 * (and the blocks components reached via Extend_Block_Core) render the same
232 * regardless of which transport answered.
233 *
234 * The V4 lookups are issued sequentially: the plugin and the SDK have no async
235 * transport, so genuine parallel execution is deferred. Repeat pageloads for the
236 * same address are served from the SDK response cache, so the second call is cheap
237 * after the first.
238 *
239 * Error semantics match the legacy single call: either service throwing aborts the
240 * whole lookup, so a failure never renders delivery days without pickup points or
241 * vice versa.
242 *
243 * The pickup gate mirrors legacy as well. Rest_API\Base_Info sends the same
244 * is_pickup_points_enabled() setting as pickup_points_enabled, and
245 * Rest_API\Legacy\Checkout\Client only adds 'Pickup' to the request options when it
246 * is set, so a merchant who disabled pickup points gets a response with no
247 * PickupOptions key on either transport and the pickup tab stays hidden. Delivery
248 * days deliberately have no matching gate: Base_Info hardcodes delivery_days_enabled
249 * to true.
250 *
251 * @param Timeframe_Service_Interface $timeframe_service Delivery-day service.
252 * @param Pickup_Location_Service_Interface $pickup_service Pickup-location service.
253 * @param array $post_data Checkout post input.
254 * @param bool $pickup_enabled Whether the merchant enabled pickup points.
255 *
256 * @return array Combined response carrying both DeliveryOptions and PickupOptions.
257 * @throws \Exception If either underlying service request fails.
258 */
259 protected static function aggregate_delivery_options( Timeframe_Service_Interface $timeframe_service, Pickup_Location_Service_Interface $pickup_service, $post_data, bool $pickup_enabled ) {
260 $response = $timeframe_service->get_delivery_options( $post_data );
261
262 // Legacy shares one instance across both flows; its single response already
263 // holds both halves, so a second call would only repeat the same request.
264 // A disabled setting skips the lookup outright, leaving PickupOptions absent.
265 if ( $pickup_service === $timeframe_service || ! $pickup_enabled ) {
266 return $response;
267 }
268
269 $pickup = $pickup_service->get_pickup_locations( $post_data );
270 $response['PickupOptions'] = isset( $pickup['PickupOptions'] ) ? $pickup['PickupOptions'] : array();
271
272 return $response;
273 }
274
275 /**
276 * Get default value for NL -> NL if nothing is picked.
277 *
278 * @param Array $response Response from checkout API.
279 * @param Array $post_data Submitted post input.
280 *
281 * @return Array.
282 */
283 public function get_default_value( $response, $post_data ) {
284 $default_val = array(
285 'val' => '',
286 'day' => '',
287 'date' => '',
288 'from' => '',
289 'to' => '',
290 'type' => '',
291 'price' => '',
292 );
293
294 if ( empty( $response['DeliveryOptions'] ) ) {
295 return $default_val;
296 }
297
298 $non_standard_fees = Base::non_standard_fees_data();
299
300 foreach ( $response['DeliveryOptions'] as $delivery_option ) {
301 if ( empty( $delivery_option['DeliveryDate'] ) || empty( $delivery_option['Timeframe'] ) ) {
302 continue;
303 }
304
305 $options = array_map(
306 function ( $timeframe ) use ( $non_standard_fees ) {
307 $type = array_shift( $timeframe['Options'] );
308 $price = isset( $non_standard_fees[ $type ] ) ? $non_standard_fees[ $type ]['fee_price'] : 0;
309
310 return array(
311 'from' => Utils::get_hour_min( $timeframe['From'] ),
312 'to' => Utils::get_hour_min( $timeframe['To'] ),
313 'type' => $type,
314 'price' => $price,
315 );
316 },
317 $delivery_option['Timeframe']
318 );
319
320 $options = array_filter(
321 $options,
322 function ( $option ) use ( $non_standard_fees ) {
323 return ! isset( $non_standard_fees[ $option['type'] ] );
324 }
325 );
326
327 if ( empty( $options ) ) {
328 continue;
329 }
330
331 $timestamp = strtotime( $delivery_option['DeliveryDate'] );
332 $default_val['day'] = gmdate( 'l', $timestamp );
333 $default_val['date'] = gmdate( 'Y-m-d', $timestamp );
334 $default_val['from'] = $options[0]['from'];
335 $default_val['to'] = $options[0]['to'];
336 $default_val['type'] = $options[0]['type'];
337 $default_val['price'] = $options[0]['price'];
338 $default_val['val'] = sanitize_title( $default_val['date'] . '_' . $default_val['from'] . '-' . $default_val['to'] . '_' . $default_val['price'] );
339
340 return $default_val;
341 }
342
343 return $default_val;
344 }
345
346 /**
347 * Get the carrier base cost by reading the currently-selected PostNL rate
348 * and subtracting the PostNL tab fee that was injected into it.
349 *
350 * @param array $post_data Checkout post data (used to determine active tab).
351 *
352 * @return float Carrier base cost (≥ 0).
353 */
354 private function get_carrier_base_cost( array $post_data ): float {
355 $option = $post_data['postnl_option'] ?? '';
356
357 $tab_fee = 0.0;
358 if ( 'delivery_day' === $option ) {
359 $tab_fee = (float) $this->settings->get_delivery_days_fee();
360 } elseif ( 'dropoff_points' === $option ) {
361 $tab_fee = (float) $this->settings->get_pickup_delivery_fee();
362 }
363
364 $chosen = WC()->session ? WC()->session->get( 'chosen_shipping_methods', array() ) : array();
365 $packages = WC()->shipping()->get_packages();
366 $supported = $this->settings->get_supported_shipping_methods();
367
368 foreach ( $packages as $i => $package ) {
369 $method_key = $chosen[ $i ] ?? '';
370
371 if ( ! isset( $package['rates'][ $method_key ] ) ) {
372 continue;
373 }
374
375 $rate = $package['rates'][ $method_key ];
376
377 if ( in_array( $rate->get_method_id(), $supported, true ) ) {
378 // The morning/evening extra fee is folded into the rate; subtract it per-package.
379 $extra_fee = 0.0;
380 if ( 'delivery_day' === $option ) {
381 $extra_fee = (float) ( $package['destination']['postnl_delivery_day_price'] ?? WC()->session->get( 'postnl_delivery_day_price', 0 ) );
382 }
383 return max( 0.0, (float) $rate->cost - $tab_fee - $extra_fee );
384 }
385 }
386
387 return 0.0;
388 }
389
390 /**
391 * Check whether a supported PostNL shipping method is the chosen method.
392 *
393 * Used to detect PostNL threshold-based free shipping: if a PostNL method is
394 * selected but its carrier base cost is 0, the minimum_for_free_shipping
395 * threshold has been reached and all PostNL fees should be suppressed.
396 *
397 * @return bool
398 */
399 private function is_postnl_method_chosen(): bool {
400 $chosen = WC()->session ? WC()->session->get( 'chosen_shipping_methods', array() ) : array();
401 $packages = WC()->shipping()->get_packages();
402 $supported = $this->settings->get_supported_shipping_methods();
403
404 foreach ( $packages as $i => $package ) {
405 $method_key = $chosen[ $i ] ?? '';
406 if ( isset( $package['rates'][ $method_key ] ) && in_array( $package['rates'][ $method_key ]->get_method_id(), $supported, true ) ) {
407 return true;
408 }
409 }
410
411 return false;
412 }
413
414 /**
415 * Add delivery day & Pickup points fields.
416 *
417 * @param array $post_data Checkout post input.
418 *
419 * @return void.
420 * @throws \Exception.
421 */
422 public function display_fields( $post_data ) {
423
424 $checkout_data = $this->get_checkout_data( $post_data );
425
426 if ( empty( $checkout_data['response'] ) ) {
427 return;
428 }
429
430 $is_free_shipping = Utils::is_free_shipping_applied();
431 $delivery_day_fee = (float) $this->settings->get_delivery_days_fee();
432 $pickup_fee = (float) $this->settings->get_pickup_delivery_fee();
433 $carrier_base_cost = $is_free_shipping ? 0.0 : $this->get_carrier_base_cost( $checkout_data['post_data'] );
434
435 // PostNL threshold-based free shipping: a PostNL method is selected but its
436 // rate was registered with cost = 0 (minimum_for_free_shipping reached).
437 if ( ! $is_free_shipping && 0.0 === $carrier_base_cost && $this->is_postnl_method_chosen() ) {
438 $is_free_shipping = true;
439 }
440
441 $tabs = $this->get_available_tabs( $checkout_data['response'] );
442 $default_tab = $this->settings->get_default_checkout_tab();
443
444 $tab_ids = array_column( $tabs, 'id' );
445 if ( ! empty( $tabs ) && ! in_array( $default_tab, $tab_ids, true ) ) {
446 $default_tab = $tabs[0]['id'];
447 }
448
449 // TODO: generalise to mirror the JS path in client/checkout/postnl-container/block.js
450 // (findIndex + splice/unshift). Today this only fires for 'dropoff_points'
451 // because there are exactly two tabs and 'delivery_day' is naturally first;
452 // adding a third tab without revisiting this branch will silently break
453 // reorder in the shortcode checkout while the blocks checkout keeps working.
454 if ( 'dropoff_points' === $default_tab && count( $tabs ) > 1 ) {
455 $preferred = array_filter( $tabs, static fn( $t ) => $t['id'] === $default_tab );
456 $rest = array_filter( $tabs, static fn( $t ) => $t['id'] !== $default_tab );
457 $tabs = array_merge( array_values( $preferred ), array_values( $rest ) );
458 }
459
460 $template_args = array(
461 'tabs' => $tabs,
462 'response' => $checkout_data['response'],
463 'post_data' => $checkout_data['post_data'],
464 'default_val' => $this->get_default_value( $checkout_data['response'], $checkout_data['post_data'] ),
465 'letterbox' => $checkout_data['letterbox'],
466 'fields' => array(
467 array(
468 'name' => $this->tab_field,
469 'value' => $this->get_tab_field_value( $checkout_data['post_data'] ),
470 ),
471 ),
472 'pickup_fee' => $pickup_fee,
473 'delivery_day_fee' => $delivery_day_fee,
474 'carrier_base_cost' => $carrier_base_cost,
475 'is_free_shipping' => $is_free_shipping,
476 'default_tab' => $default_tab,
477 );
478
479 wc_get_template( 'checkout/postnl-container.php', $template_args, '', POSTNL_WC_PLUGIN_DIR_PATH . '/templates/' );
480 }
481
482 /**
483 * Check address and display fields.
484 *
485 * @return void.
486 */
487 public function postnl_fields() {
488 try {
489 $post_data = $this->get_checkout_post_data();
490
491 if ( empty( $post_data ) ) {
492 return;
493 }
494
495 $sipping_methods = $this->settings->get_supported_shipping_methods();
496
497 foreach ( $post_data as $post_key => $post_value ) {
498 if ( 'shipping_method' === $post_key && ! in_array( Utils::get_cart_shipping_method_id( $post_value[0] ), $sipping_methods ) ) {
499 // Clear PostNL session data when shipping method is not supported.
500 Utils::clear_postnl_checkout_session();
501 return;
502 }
503 }
504
505 $available_country = Mapping::available_country_for_checkout_feature();
506 $store_country = Utils::get_base_country();
507
508 // To fix cache issues, check billing country if it is the same address for shipping.
509 if ( ! empty( $post_data['ship_to_different_address'] ) ) {
510 $receiver_country = ! empty( $post_data['shipping_country'] ) ? $post_data['shipping_country'] : '';
511 } else {
512 $receiver_country = ! empty( $post_data['billing_country'] ) ? $post_data['billing_country'] : '';
513 }
514
515 if ( ! isset( $available_country[ $store_country ][ $receiver_country ] ) ) {
516 // Clear PostNL session data when country is not supported.
517 Utils::clear_postnl_checkout_session();
518 return;
519 }
520
521 $post_data = Address_Utils::set_post_data_address( $post_data );
522
523 if ( empty( $post_data['shipping_postcode'] ) ) {
524 // Clear PostNL session data when postcode is missing.
525 Utils::clear_postnl_checkout_session();
526 return;
527 }
528
529 // Validate address.
530 if ( $this->is_address_validation_required() ) {
531 $is_reorder_nl_address_enabled = $this->settings->is_reorder_nl_address_enabled();
532
533 if ( empty( $post_data['shipping_house_number'] ) && $is_reorder_nl_address_enabled ) {
534 // Clear PostNL session data when house number is missing.
535 Utils::clear_postnl_checkout_session();
536 return;
537 } elseif ( empty( $post_data['shipping_house_number'] ) && ! $is_reorder_nl_address_enabled ) {
538 throw new \Exception( 'Address does not contain house number!' );
539 }
540
541 $this->validated_address( $post_data );
542 }
543
544 // Display PostNL Delivery day & Pickup points.
545 $this->display_fields( $post_data );
546
547 } catch ( \Exception $e ) {
548 wc_add_notice( $e->getMessage(), 'error' );
549 }
550 }
551
552 /**
553 * Check address by PostNL Checkout Rest API.
554 *
555 * @param Array $post_data Checkout post data.
556 */
557 public function validated_address( $post_data ) {
558 $response = $this->service_factory()->postcode_check_service()->check( $post_data );
559
560 if ( empty( $response[0] ) ) {
561 // Clear validated address.
562 WC()->session->set( POSTNL_SETTINGS_ID . '_validated_address', array() );
563 // Mark the address as invalid in the session:
564 WC()->session->set( POSTNL_SETTINGS_ID . '_invalid_address_marker', true );
565 // Add notice without blocking checkout call.
566 wc_add_notice( esc_html__( 'This is not a valid address!', 'postnl-for-woocommerce' ), 'notice' );
567 } else {
568 // Set validated address.
569 WC()->session->set(
570 POSTNL_SETTINGS_ID . '_validated_address',
571 array(
572 'city' => $response[0]['city'],
573 'street' => $response[0]['streetName'],
574 'house_number' => $response[0]['houseNumber'],
575 'ship_to_different_address' => ! empty( $post_data['ship_to_different_address'] ),
576 )
577 );
578 WC()->session->__unset( POSTNL_SETTINGS_ID . '_invalid_address_marker' );
579 }
580 }
581
582 /**
583 * Fill checkout form fields after address validation.
584 *
585 * @param Array $fragments Cart fragments.
586 *
587 * @return mixed
588 */
589 public function fill_validated_address( $fragments ) {
590 if ( ! $this->settings->is_validate_nl_address_enabled() ) {
591 return $fragments;
592 }
593
594 $validated_address = WC()->session->get( POSTNL_SETTINGS_ID . '_validated_address' );
595
596 if ( ! is_array( $validated_address ) || empty( $validated_address ) ) {
597 return $fragments;
598 }
599
600 if ( $validated_address['ship_to_different_address'] ) {
601 $address_type = 'shipping';
602 } else {
603 $address_type = 'billing';
604 }
605
606 // Fill Address 1 with street name & house number if fields reordering disabled.
607 if ( ! $this->settings->is_reorder_nl_address_enabled() ) {
608 $address_1 = $validated_address['street'] . ' ' . $validated_address['house_number'];
609 } else {
610 $address_1 = $validated_address['street'];
611 }
612 $fragments[ '#' . $address_type . '_address_1' ] = '<input type="text" class="input-text " name="' . $address_type . '_address_1" id="' . $address_type . '_address_1" value="' . $address_1 . '" autocomplete="address-line1">';
613
614 $fragments[ '#' . $address_type . '_city' ] = '<input type="text" class="input-text " name="' . $address_type . '_city" id="' . $address_type . '_city" placeholder="" value="' . $validated_address['city'] . '" autocomplete="address-level2">';
615
616 return $fragments;
617 }
618
619 /**
620 * Add cart fees.
621 *
622 * Morning/evening delivery fees are folded directly into the shipping rate
623 * cost via inject_postnl_base_fees(), so no separate fee line item is needed.
624 *
625 * @param \WC_Cart $cart Cart object.
626 */
627 public function add_cart_fees( $cart ) {
628 return;
629 }
630
631 /**
632 * Collapse every PostNL-linked shipping method into a single canonical letterbox
633 * option set when the cart is eligible for automatic letterbox delivery (ALA).
634 *
635 * This filter is the single source of truth for the letterbox rate set. When ALA
636 * succeeds it:
637 * - keeps non-linked carriers (e.g. DHL) untouched, including a non-linked
638 * Free Shipping method, which stays visible and never affects the letterbox cost;
639 * - drops every PostNL-linked rate (the PostNL method's own rate, any linked
640 * Flat Rate instances, and a linked Free Shipping method) and emits ONE canonical
641 * 24h/48h option set in their place, so the choice appears exactly once regardless
642 * of how many linked methods or instances the zone has. A linked Free Shipping
643 * method still applies its waiver (the canonical cost drops to 0) but is not shown
644 * as a separate row.
645 *
646 * PostNL::calculate_shipping() deliberately does NOT emit its own letterbox variants
647 * — it emits a plain PostNL rate that this filter folds into the canonical set, so
648 * there is a single emitter and no duplication.
649 *
650 * @since 5.9.6
651 *
652 * @param array $rates Shipping rates keyed by rate ID.
653 * @param array $package Shipping package.
654 * @return array
655 */
656 public function inject_letterbox_rates_for_all_methods( $rates, $package ) {
657 // Only apply for NL base country.
658 if ( 'NL' !== Utils::get_base_country() ) {
659 return $rates;
660 }
661
662 // Check cart eligibility for letterbox.
663 if ( ! Utils::is_cart_eligible_auto_letterbox( \WC()->cart ) ) {
664 return $rates;
665 }
666
667 $letterbox_product_type = $this->settings->get_default_automatic_letterboxparcel_product();
668
669 // Only inject when customer can decide or a specific letterbox type is configured.
670 if ( ! in_array( $letterbox_product_type, array( 'customer_decide', 'letterbox', 'letterbox_48' ), true ) ) {
671 return $rates;
672 }
673
674 // Idempotency guard: if the canonical letterbox set is already present (this filter
675 // ran earlier in the same request), do not rebuild it — that would nest a second
676 // ':letterbox' suffix onto an existing variant and duplicate the options.
677 foreach ( $rates as $rate ) {
678 $rate_meta = $rate->get_meta_data();
679 if ( isset( $rate_meta['letterbox_type'] ) ) {
680 return $rates;
681 }
682 }
683
684 $supported = $this->settings->get_supported_shipping_methods();
685
686 // Partition rates: non-linked carriers (including a non-linked Free Shipping method)
687 // are kept as-is; every PostNL-linked rate is collapsed into one canonical letterbox
688 // option set below.
689 $kept_rates = array();
690 $linked_rates = array();
691 $has_free_shipping = false;
692
693 foreach ( $rates as $rate_id => $rate ) {
694 $method_id = $rate->get_method_id();
695
696 if ( 'free_shipping' === $method_id ) {
697 // A Free Shipping method only waives the letterbox cost when the
698 // merchant has explicitly linked it to PostNL. When linked, it is
699 // collapsed like any other PostNL-linked rate: its waiver effect is
700 // applied (the canonical letterbox cost drops to 0) but the method
701 // itself is not shown as a separate row alongside the letterbox
702 // options. A standalone (non-linked) Free Shipping option is
703 // independent: it never zeros out the letterbox prices and is kept
704 // visible in checkout.
705 if ( in_array( $method_id, $supported, true ) ) {
706 $has_free_shipping = true;
707 } else {
708 $kept_rates[ $rate_id ] = $rate;
709 }
710 continue;
711 }
712
713 if ( ! in_array( $method_id, $supported, true ) ) {
714 $kept_rates[ $rate_id ] = $rate;
715 continue;
716 }
717
718 $linked_rates[ $rate_id ] = $rate;
719 }
720
721 // No PostNL-linked rates to collapse — leave the package untouched.
722 if ( empty( $linked_rates ) ) {
723 return $rates;
724 }
725
726 // Free-shipping determination drives both variants to 0. It is derived ONLY
727 // from PostNL-linked signals: a linked Free Shipping method being present
728 // ($has_free_shipping) or a linked carrier rate at cost 0 (handled in the loop
729 // below, where the PostNL free-shipping threshold has been met). A non-linked
730 // Free Shipping method — even one the customer has selected — must not waive
731 // the letterbox cost, so Utils::is_free_shipping_applied() is intentionally not
732 // consulted here (it ignores linkage and would re-introduce that regression).
733 $is_free = $has_free_shipping;
734
735 // Cheapest linked carrier cost is the base-cost fallback when no letterbox_fee is set.
736 $cheapest_cost = null;
737 foreach ( $linked_rates as $rate ) {
738 $cost = (float) $rate->get_cost();
739 if ( 0.0 === $cost ) {
740 // A linked rate at cost 0 means the PostNL free-shipping threshold was met.
741 $is_free = true;
742 }
743 if ( null === $cheapest_cost || $cost < $cheapest_cost ) {
744 $cheapest_cost = $cost;
745 }
746 }
747 $cheapest_cost = ( null === $cheapest_cost ) ? 0.0 : $cheapest_cost;
748
749 $letterbox_fee = $this->settings->get_letterbox_fee();
750 $base_cost = ( null !== $letterbox_fee ) ? (float) $letterbox_fee : $cheapest_cost;
751 $base_cost = $is_free ? 0.0 : $base_cost;
752 $effective_fee_24h = $is_free ? 0.0 : (float) $this->settings->get_letterbox_24_fee();
753
754 $rep_rate_id = null;
755 $rep_rate = null;
756 foreach ( $linked_rates as $rate_id => $rate ) {
757 if ( POSTNL_SETTINGS_ID === $rate->get_method_id() ) {
758 $rep_rate_id = $rate_id;
759 $rep_rate = $rate;
760 break;
761 }
762 }
763 if ( null === $rep_rate ) {
764 $rep_rate_id = array_key_first( $linked_rates );
765 $rep_rate = $linked_rates[ $rep_rate_id ];
766 }
767
768 $rep_method_id = $rep_rate->get_method_id();
769 $rep_instance = $rep_rate->get_instance_id();
770
771 // Recalculate shipping taxes for the canonical cost.
772 $calc_taxes = function ( $cost ) use ( $rep_rate ) {
773 if ( wc_tax_enabled() && 'taxable' === $rep_rate->get_tax_status() ) {
774 return \WC_Tax::calc_shipping_tax( $cost, \WC_Tax::get_shipping_tax_rates() );
775 }
776 return array();
777 };
778
779 // Build the canonical letterbox option set ONCE.
780 $canonical = array();
781
782 if ( 'customer_decide' === $letterbox_product_type || 'letterbox' === $letterbox_product_type ) {
783 $cost_24h = $base_cost + $effective_fee_24h;
784
785 $rate_24h = new \WC_Shipping_Rate(
786 $rep_rate_id . ':letterbox',
787 Utils::get_letterbox_label_24h(),
788 $cost_24h,
789 $calc_taxes( $cost_24h ),
790 $rep_method_id,
791 $rep_instance
792 );
793 $rate_24h->add_meta_data( 'letterbox_type', 'letterbox' );
794 $canonical[ $rep_rate_id . ':letterbox' ] = $rate_24h;
795 }
796
797 if ( 'customer_decide' === $letterbox_product_type || 'letterbox_48' === $letterbox_product_type ) {
798 $rate_48h = new \WC_Shipping_Rate(
799 $rep_rate_id . ':letterbox_48',
800 Utils::get_letterbox_label_48h(),
801 $base_cost,
802 $calc_taxes( $base_cost ),
803 $rep_method_id,
804 $rep_instance
805 );
806 $rate_48h->add_meta_data( 'letterbox_type', 'letterbox_48' );
807 $canonical[ $rep_rate_id . ':letterbox_48' ] = $rate_48h;
808 }
809
810 // Canonical letterbox option(s) first, then the kept rates (non-linked + free_shipping).
811 return $canonical + $kept_rates;
812 }
813
814 /**
815 * Add the shipping option fees to the shipping methods
816 *
817 * @param array $rates.
818 * @return array
819 */
820 public function inject_postnl_base_fees( $rates, $package ) {
821 if ( Utils::is_free_shipping_applied() ) {
822 return $rates;
823 }
824
825 // Letterbox-eligible carts are owned by the variant emitters
826 if ( Utils::is_cart_eligible_auto_letterbox( \WC()->cart ) ) {
827 return $rates;
828 }
829
830 $option = $package['destination']['postnl_option'] ?? WC()->session->get( 'postnl_option', '' );
831 if ( '' === $option ) {
832 return $rates;
833 }
834
835 $pickup_fee = (float) $this->settings->get_pickup_delivery_fee();
836 $base_day_fee = (float) $this->settings->get_delivery_days_fee();
837 $supported = $this->settings->get_supported_shipping_methods();
838
839 foreach ( $rates as $rate_id => $rate ) {
840 if ( ! in_array( $rate->get_method_id(), $supported, true ) ) {
841 continue;
842 }
843
844 // PostNL threshold-based free shipping: the rate was registered with
845 // cost = 0 by PostNL::calculate_shipping(). Do not inject any fees.
846 if ( 0.0 === (float) $rate->cost ) {
847 continue;
848 }
849
850 $extra = 0;
851 if ( 'dropoff_points' === $option && $pickup_fee > 0 ) {
852 $extra = $pickup_fee;
853 } elseif ( 'delivery_day' === $option ) {
854 // Fold both the tab base fee and any morning/evening extra fee into the rate.
855 // Prefer the package destination value (set by add_postnl_option_to_package during
856 // AJAX calls) and fall back to the session value for order placement, when
857 // $_REQUEST['post_data'] is no longer available.
858 $extra = $base_day_fee;
859 $extra += (float) ( $package['destination']['postnl_delivery_day_price'] ?? WC()->session->get( 'postnl_delivery_day_price', 0 ) );
860 }
861
862 if ( $extra <= 0 ) {
863 continue;
864 }
865
866 $rate->cost += $extra;
867
868 if ( wc_tax_enabled() && 'taxable' === $rate->get_tax_status() ) {
869 $tax_rates = \WC_Tax::get_shipping_tax_rates();
870 $rate->taxes = \WC_Tax::calc_shipping_tax( $rate->cost, $tax_rates );
871 }
872 }
873
874 return $rates;
875 }
876
877
878 /**
879 * Include the selected PostNL option in the shipping package
880 *
881 * @param array $packages Shipping packages.
882 * @return array
883 */
884 public function add_postnl_option_to_package( $packages ) {
885 $post_data = $this->get_checkout_post_data();
886 $option = $post_data['postnl_option'] ?? '';
887
888 // Blocks-checkout fallback: Extend_Block_Core::postnl_store_api_callback
889 // writes postnl_delivery_type/postnl_delivery_fee to session before this
890 // filter fires. Mirror that state into $option so the destination injection
891 // below runs on the blocks path too — without it, the WC_Shipping package
892 // hash never changes on tab switch and add_postnl_fees_to_rates() is never
893 // re-invoked against fresh rates. WC ≤ 10.4 accidentally masked this via
894 // divergent package shapes in CartController::get_shipping_packages(); that
895 // divergence was removed, exposing the gap.
896 if ( '' === $option && WC()->session ) {
897 $session_type = WC()->session->get( 'postnl_delivery_type', '' );
898 if ( 'Pickup' === $session_type ) {
899 $option = 'dropoff_points';
900 } elseif ( '' !== $session_type ) {
901 $option = 'delivery_day';
902 }
903 }
904
905 if ( '' === $option ) {
906 return $packages;
907 }
908
909 WC()->session->set( 'postnl_option', $option );
910
911 // Store the morning/evening extra fee in the destination so the shipping
912 // rate cache key changes when the selection changes, forcing recalculation.
913 // Also persist to session so inject_postnl_base_fees can read it during
914 // order placement, when $_REQUEST['post_data'] is no longer available.
915 $raw_price = $post_data['postnl_delivery_day_price']
916 ?? WC()->session->get( 'postnl_delivery_fee', 0 );
917
918 $delivery_day_price = ( 'delivery_day' === $option )
919 ? (string) (float) $raw_price
920 : '0';
921
922 WC()->session->set( 'postnl_delivery_day_price', $delivery_day_price );
923
924 foreach ( $packages as $key => $package ) {
925 $packages[ $key ]['destination']['postnl_option'] = $option;
926 $packages[ $key ]['destination']['postnl_delivery_day_price'] = $delivery_day_price;
927 }
928
929 return $packages;
930 }
931
932 /**
933 * Check if address validation required.
934 *
935 * @return bool
936 */
937 public function is_address_validation_required() {
938 if ( ! $this->settings->is_validate_nl_address_enabled() ) {
939 return false;
940 }
941
942 if ( 'NL' !== Address_Utils::get_customer_billing_country() && 'NL' !== Address_Utils::get_customer_shipping_country() ) {
943 return false;
944 }
945
946 return true;
947 }
948
949 /**
950 * Replace shipping method title with Icon.
951 *
952 * @param String $label String of label html.
953 * @param \WC_Shipping_Rate $method Shipping method object.
954 *
955 * @return string
956 */
957 public function add_shipping_method_icon( $label, $method ) {
958 if ( POSTNL_SETTINGS_ID === $method->get_method_id() ) {
959 $method_title = $method->get_label();
960 $label = '<img src="' . esc_url( trailingslashit( POSTNL_WC_PLUGIN_DIR_URL ) . 'assets/images/postnl-new-brand-logo.png' ) . '" class="postnl_shipping_method_icon" alt="' . $method_title . '" />' . $label;
961 }
962
963 return $label;
964 }
965 }
966