settings = postnl()->get_shipping_settings(); // Initialize hooks add_action( 'woocommerce_store_api_checkout_update_order_from_request', array( $this, 'save_postnl_checkout_fields', ), 10, 2 ); // Register the update callback when WooCommerce Blocks is loaded add_action( 'init', array( $this, 'register_store_api_callback' ) ); add_action( 'woocommerce_cart_calculate_fees', array( $this, 'postnl_add_custom_fee' ) ); add_filter( 'woocommerce_package_rates', array( $this, 'add_postnl_fees_to_rates' ), 20, 2 ); if ( $this->settings->is_reorder_nl_address_enabled() ) { $this->register_additional_checkout_fields(); } // Validate adress in cart add_action( 'woocommerce_store_api_cart_errors', array( $this, 'postnl_validate_address_in_cart' ), 10, 2 ); /** * Registers AJAX actions. */ add_action( 'wp_ajax_postnl_set_checkout_post_data', array( $this, 'handle_set_checkout_post_data' ) ); add_action( 'wp_ajax_nopriv_postnl_set_checkout_post_data', array( $this, 'handle_set_checkout_post_data' ) ); } /** * Validate address in cart. * Only shows validation error for NL addresses. */ public function postnl_validate_address_in_cart( $errors, $cart ) { // Only validate NL addresses $shipping_country = WC()->customer ? WC()->customer->get_shipping_country() : ''; if ( 'NL' !== $shipping_country ) { // Clear any stale invalid marker for non-NL countries WC()->session->__unset( POSTNL_SETTINGS_ID . '_invalid_address_marker' ); return; } $invalid_marker = WC()->session->get( POSTNL_SETTINGS_ID . '_invalid_address_marker', false ); if ( $invalid_marker ) { $errors->add( 'invalid_address', esc_html__( 'This is not a valid address!', 'postnl-for-woocommerce' ) ); } } /** * Register additional checkout fields . */ public function register_additional_checkout_fields() { add_action( 'init', function () { woocommerce_register_additional_checkout_field( array( 'id' => 'postnl/house_number', 'label' => __( 'House number', 'postnl-for-woocommerce' ), 'location' => 'address', 'required' => false, 'attributes' => array( 'autocomplete' => 'house_number', ), ), ); } ); } /** * Register the update callback with WooCommerce Store API */ public function register_store_api_callback() { if ( function_exists( 'woocommerce_store_api_register_update_callback' ) ) { woocommerce_store_api_register_update_callback( array( 'namespace' => $this->name, 'callback' => array( $this, 'postnl_store_api_callback' ), ) ); } } /** * Callback function for the Store API update * * @param array $data Data sent from the client. */ public function postnl_store_api_callback( $data ) { if ( isset( $data['action'] ) && 'update_delivery_fee' === $data['action'] ) { $price = isset( $data['price'] ) ? floatval( $data['price'] ) : 0; $type = isset( $data['type'] ) ? sanitize_text_field( $data['type'] ) : ''; // Store the fee amount and type in session WC()->session->set( 'postnl_delivery_fee', $price ); WC()->session->set( 'postnl_delivery_type', $type ); } else { WC()->session->__unset( 'postnl_delivery_fee' ); WC()->session->__unset( 'postnl_delivery_type' ); } } /** * Adds the delivery fee to the WooCommerce cart. * * Morning/evening delivery fees are folded directly into the shipping rate * cost via add_postnl_fees_to_rates(), so no separate fee line item is added. * * @param \WC_Cart $cart The WooCommerce cart object. */ public function postnl_add_custom_fee( $cart ) { return; } /** * Add Postnl fees to the supported shipping rates * * @param array $rates * @param array $package * @return array */ public function add_postnl_fees_to_rates( $rates, $package ) { if ( Utils::is_free_shipping_applied() ) { return $rates; } // Letterbox-eligible carts are owned by the variant emitters // (inject_letterbox_rates_for_all_methods + PostNL::calculate_shipping). // No tab-based extras apply on top — per spec, when ALA triggers the // home delivery / pickup / delivery day surcharges are ignored. if ( Utils::is_cart_eligible_auto_letterbox( WC()->cart ) ) { return $rates; } $session_type = WC()->session->get( 'postnl_delivery_type', '' ); if ( '' === $session_type ) { return $rates; } $pickup_fee = $this->settings->get_pickup_delivery_fee(); $base_day_fee = $this->settings->get_delivery_days_fee(); $supported = $this->settings->get_supported_shipping_methods(); foreach ( $rates as $rate_id => $rate ) { if ( ! in_array( $rate->get_method_id(), $supported, true ) ) { continue; } // PostNL threshold-based free shipping: the rate was registered with // cost = 0 by PostNL::calculate_shipping(). Do not inject any fees. if ( 0.0 === (float) $rate->cost ) { continue; } $extra = 0; if ( 'Pickup' === $session_type && $pickup_fee > 0 ) { $extra = $pickup_fee; } elseif ( 'Pickup' !== $session_type ) { // Fold both the tab base fee and any morning/evening extra fee into the rate. $extra = $base_day_fee + (float) WC()->session->get( 'postnl_delivery_fee', 0 ); } if ( $extra <= 0 ) { continue; } $rate->cost += $extra; if ( wc_tax_enabled() && 'taxable' === $rate->get_tax_status() ) { $tax_rates = \WC_Tax::get_shipping_tax_rates(); $rate->taxes = \WC_Tax::calc_shipping_tax( $rate->cost, $tax_rates ); } } return $rates; } /** * Get the carrier base cost by reading the currently-selected PostNL rate * and subtracting the PostNL fees that were injected into it. * * The injected amount is: tab base fee + session extra fee (morning/evening). * Returns the raw carrier cost before PostNL touched the rate. * * @return float Carrier base cost (≥ 0), pre-tax. */ private function get_carrier_base_cost_for_blocks(): float { $session_type = WC()->session->get( 'postnl_delivery_type', '' ); $session_fee = (float) WC()->session->get( 'postnl_delivery_fee', 0 ); $pickup_fee = (float) $this->settings->get_pickup_delivery_fee(); $base_day_fee = (float) $this->settings->get_delivery_days_fee(); $supported = $this->settings->get_supported_shipping_methods(); $injected = 0.0; if ( 'Pickup' === $session_type ) { $injected = $pickup_fee; } elseif ( '' !== $session_type ) { $injected = $base_day_fee + $session_fee; } $chosen = WC()->session ? WC()->session->get( 'chosen_shipping_methods', array() ) : array(); WC()->cart->calculate_shipping(); $packages = WC()->shipping()->get_packages(); foreach ( $packages as $i => $package ) { $method_key = $chosen[ $i ] ?? ''; if ( ! isset( $package['rates'][ $method_key ] ) ) { continue; } $rate = $package['rates'][ $method_key ]; if ( in_array( $rate->get_method_id(), $supported, true ) ) { return max( 0.0, (float) $rate->cost - $injected ); } } return 0.0; } /** * Check whether a supported PostNL shipping method is the chosen method. * * Used to detect PostNL threshold-based free shipping: if a PostNL method is * selected but its carrier base cost is 0, the minimum_for_free_shipping * threshold has been reached and all PostNL fees should be suppressed. * * @return bool */ private function is_postnl_method_chosen(): bool { $chosen = WC()->session ? WC()->session->get( 'chosen_shipping_methods', array() ) : array(); $packages = WC()->shipping()->get_packages(); $supported = $this->settings->get_supported_shipping_methods(); foreach ( $packages as $i => $package ) { $method_key = $chosen[ $i ] ?? ''; if ( isset( $package['rates'][ $method_key ] ) && in_array( $package['rates'][ $method_key ]->get_method_id(), $supported, true ) ) { return true; } } return false; } /** * Saves the PostNL delivery day or dropoff points fields to the order's metadata. * * @param \WC_Order $order Order object. * @param \WP_REST_Request $request REST request object. */ public function save_postnl_checkout_fields( \WC_Order $order, \WP_REST_Request $request ) { $postnl_request_data = $request['extensions'][ $this->name ]; // Extract billing and shipping house numbers with sanitization $shipping_house_number = ''; if ( ! empty( $postnl_request_data['houseNumber'] ) && $this->settings->is_reorder_nl_address_enabled() ) { $shipping_house_number = sanitize_text_field( $postnl_request_data['houseNumber'] ); } // Update billing and shipping house numbers $order->update_meta_data( '_shipping_house_number', $shipping_house_number ); /** * Prepare Pickup Data */ $drop_data = array( 'frontend' => array( 'dropoff_points' => isset( $postnl_request_data['dropoffPoints'] ) ? sanitize_text_field( $postnl_request_data['dropoffPoints'] ) : '', 'dropoff_points_address_company' => isset( $postnl_request_data['dropoffPointsAddressCompany'] ) ? sanitize_text_field( $postnl_request_data['dropoffPointsAddressCompany'] ) : '', 'dropoff_points_distance' => isset( $postnl_request_data['dropoffPointsDistance'] ) ? sanitize_text_field( $postnl_request_data['dropoffPointsDistance'] ) : '', 'dropoff_points_address_address_1' => isset( $postnl_request_data['dropoffPointsAddress1'] ) ? sanitize_text_field( $postnl_request_data['dropoffPointsAddress1'] ) : '', 'dropoff_points_address_address_2' => isset( $postnl_request_data['dropoffPointsAddress2'] ) ? sanitize_text_field( $postnl_request_data['dropoffPointsAddress2'] ) : '', 'dropoff_points_address_city' => isset( $postnl_request_data['dropoffPointsCity'] ) ? sanitize_text_field( $postnl_request_data['dropoffPointsCity'] ) : '', 'dropoff_points_address_postcode' => isset( $postnl_request_data['dropoffPointsPostcode'] ) ? sanitize_text_field( $postnl_request_data['dropoffPointsPostcode'] ) : '', 'dropoff_points_address_country' => isset( $postnl_request_data['dropoffPointsCountry'] ) ? sanitize_text_field( $postnl_request_data['dropoffPointsCountry'] ) : '', 'dropoff_points_partner_id' => isset( $postnl_request_data['dropoffPointsPartnerID'] ) ? sanitize_text_field( $postnl_request_data['dropoffPointsPartnerID'] ) : '', 'dropoff_points_date' => isset( $postnl_request_data['dropoffPointsDate'] ) ? sanitize_text_field( $postnl_request_data['dropoffPointsDate'] ) : '', 'dropoff_points_time' => isset( $postnl_request_data['dropoffPointsTime'] ) ? sanitize_text_field( $postnl_request_data['dropoffPointsTime'] ) : '', 'dropoff_points_type' => isset( $postnl_request_data['dropoffPointsType'] ) ? sanitize_text_field( $postnl_request_data['dropoffPointsType'] ) : '', ), ); /** * Prepare Delivery Day Data */ $delivery_day_data = array( 'frontend' => array( 'delivery_day' => isset( $postnl_request_data['deliveryDay'] ) ? sanitize_text_field( $postnl_request_data['deliveryDay'] ) : '', 'delivery_day_date' => isset( $postnl_request_data['deliveryDayDate'] ) ? sanitize_text_field( $postnl_request_data['deliveryDayDate'] ) : '', 'delivery_day_from' => isset( $postnl_request_data['deliveryDayFrom'] ) ? sanitize_text_field( $postnl_request_data['deliveryDayFrom'] ) : '', 'delivery_day_to' => isset( $postnl_request_data['deliveryDayTo'] ) ? sanitize_text_field( $postnl_request_data['deliveryDayTo'] ) : '', 'delivery_day_price' => isset( $postnl_request_data['deliveryDayPrice'] ) ? sanitize_text_field( $postnl_request_data['deliveryDayPrice'] ) : '', 'delivery_day_type' => isset( $postnl_request_data['deliveryDayType'] ) ? sanitize_text_field( $postnl_request_data['deliveryDayType'] ) : '', ), ); if ( ! empty( $drop_data['frontend']['dropoff_points'] ) ) { $order->update_meta_data( '_postnl_order_metadata', $drop_data ); } elseif ( ! empty( $delivery_day_data['frontend']['delivery_day'] ) ) { // Save Delivery Day Data $order->update_meta_data( '_postnl_order_metadata', $delivery_day_data ); } // Save letterbox type from shipping method selection. $this->save_letterbox_type_from_shipping( $order ); /** * Save the order to persist changes */ $order->save_meta_data(); } /** * Save the letterbox type from the selected shipping method in blocks checkout. * * @param \WC_Order $order Order object. */ private function save_letterbox_type_from_shipping( $order ) { $shipping_methods = $order->get_shipping_methods(); if ( empty( $shipping_methods ) ) { return; } $supported = $this->settings->get_supported_shipping_methods(); $valid = array( 'letterbox', 'letterbox_48' ); foreach ( $shipping_methods as $shipping_item ) { // Restrict to PostNL-supported methods so a third-party plugin's // own 'letterbox_type' shipping-item meta cannot leak into ours. if ( ! in_array( $shipping_item->get_method_id(), $supported, true ) ) { continue; } $letterbox_type = $shipping_item->get_meta( 'letterbox_type' ); if ( in_array( $letterbox_type, $valid, true ) ) { $order->update_meta_data( '_postnl_letterbox_type', $letterbox_type ); return; } } } /** * Handle AJAX request to set checkout post data and return updated delivery options. */ public function handle_set_checkout_post_data() { // Verify nonce if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'postnl_delivery_day_nonce' ) ) { wp_send_json_error( array( 'message' => 'Invalid nonce' ), 400 ); wp_die(); } // Check if data is provided if ( ! isset( $_POST['data'] ) || ! is_array( $_POST['data'] ) ) { wp_send_json_error( array( 'message' => 'No data provided.' ), 400 ); wp_die(); } // Sanitize data $sanitized_data = array_map( 'sanitize_text_field', wp_unslash( $_POST['data'] ) ); $original_shipping_address_1 = $sanitized_data['shipping_address_1'] ?? ''; $sanitized_data = Address_Utils::set_post_data_address( $sanitized_data ); $shipping_country = isset( $sanitized_data['shipping_country'] ) ? $sanitized_data['shipping_country'] : ''; // Save the house number and postcode on WC customer if provided if ( isset( $sanitized_data['shipping_house_number'] ) && isset( $sanitized_data['shipping_postcode'] ) ) { WC()->customer->set_shipping_postcode( $sanitized_data['shipping_postcode'] ); WC()->customer->update_meta_data( '_wc_shipping/postnl/house_number', $sanitized_data['shipping_house_number'] ); WC()->customer->set_shipping_country( $sanitized_data['shipping_country'] ); WC()->customer->set_shipping_address_2( $sanitized_data['shipping_address_2'] ?? '' ); WC()->customer->save(); } // If not NL, clear session and return if ( ! in_array( $shipping_country, array( 'NL', 'BE' ), true ) ) { Utils::clear_postnl_checkout_session(); wp_send_json_success( array( 'message' => 'No delivery options available outside NL.', 'show_container' => false, 'delivery_options' => array(), 'dropoff_options' => array(), ), 200 ); wp_die(); } // Check if required fields are present if ( empty( $sanitized_data['shipping_postcode'] ) || ( $this->settings->is_reorder_nl_address_enabled() && empty( $sanitized_data['shipping_house_number'] ) && 'NL' == $shipping_country ) ) { Utils::clear_postnl_checkout_session(); wp_send_json_success( array( 'message' => esc_html__( 'Postcode or house number is missing.', 'postnl-for-woocommerce' ), 'show_container' => false, 'delivery_options' => array(), 'dropoff_options' => array(), ), 200 ); wp_die(); } // Retrieve previously sanitized data from session $previous_sanitized_data = WC()->session->get( 'postnl_checkout_post_data' ); $address_changed = $previous_sanitized_data !== $sanitized_data; if ( $address_changed ) { // Clear previous validated address WC()->session->__unset( POSTNL_SETTINGS_ID . '_validated_address' ); } // Retrieve validated address from session $validated_address = WC()->session->get( POSTNL_SETTINGS_ID . '_validated_address' ); // Construct without registering hooks: this instance is only used for its // helper methods below. The bootstrap Container (Main::get_frontend(), fired // on the init hook — which also runs during admin-ajax) already owns the // global woocommerce_package_rates filters. Re-registering them here would // run inject_letterbox_rates_for_all_methods twice during this request's // shipping calculation and duplicate the letterbox 24h/48h rates. $container = new Container( false ); // If validation is enabled and address changed or not validated yet if ( $container->is_address_validation_required() && 'NL' === $shipping_country && ( $address_changed || empty( $validated_address ) ) ) { try { $container->validated_address( $sanitized_data ); // Attempt to fetch validated address again $validated_address = WC()->session->get( POSTNL_SETTINGS_ID . '_validated_address' ); } catch ( \Exception $e ) { } } // Store data in WooCommerce session WC()->session->set( 'postnl_checkout_post_data', $sanitized_data ); // Save address_1 and city if available if ( ! empty( $validated_address ) && isset( $validated_address['street'], $validated_address['city'] ) && $validated_address['street'] !== '' && $validated_address['city'] !== '' ) { // Use validated data if ( $this->settings->is_reorder_nl_address_enabled() ) { // Separate house number field is enabled, use street only WC()->customer->set_shipping_address_1( $validated_address['street'] ); } else { // House number is part of address_1, combine street and house number. $house_number = $validated_address['house_number'] ?? ''; $address_1 = $validated_address['street'] . ' ' . $house_number; WC()->customer->set_shipping_address_1( $address_1 ); } WC()->customer->set_shipping_city( $validated_address['city'] ); } else { if ( $this->settings->is_reorder_nl_address_enabled() ) { WC()->customer->set_shipping_address_1( $sanitized_data['shipping_address_1'] ?? '' ); } else { WC()->customer->set_shipping_address_1( $original_shipping_address_1 ); } WC()->customer->set_shipping_city( $sanitized_data['shipping_city'] ?? '' ); WC()->customer->set_shipping_state( $sanitized_data['shipping_state'] ?? '' ); } // Save the customer data WC()->customer->save(); // Check letterbox (ALA) eligibility *after* the customer's shipping // country has been persisted above. is_cart_eligible_auto_letterbox() // reads that saved country, so evaluating it earlier made the first // AJAX call on a fresh session read a stale country and wrongly report // not-eligible — which left the blocks delivery-day / pickup tabs // unblocked until a full page reload. $letterbox = Utils::is_cart_eligible_auto_letterbox( WC()->cart ); // Proceed to fetch delivery and dropoff options $order_data = $sanitized_data; $delivery_day = new Delivery_Day(); $dropoff = new Dropoff_Points(); $checkout_data = $container->get_checkout_data( $order_data ); $delivery_options = $delivery_day->get_content_data( $checkout_data['response'], $checkout_data['post_data'] ); $dropoff_options = $dropoff->get_content_data( $checkout_data['response'], $checkout_data['post_data'] ); $is_free_shipping = Utils::is_free_shipping_applied(); // Compute tax-display-adjusted values for block checkout tab labels. $raw_carrier_base_cost = $this->get_carrier_base_cost_for_blocks(); $carrier_base_cost = Utils::get_fee_total_price( $raw_carrier_base_cost ); $delivery_day_fee_display = Utils::get_fee_total_price( (float) $this->settings->get_delivery_days_fee() ); $pickup_fee_display = Utils::get_fee_total_price( (float) $this->settings->get_pickup_delivery_fee() ); // The WC Store API returns shipping rate `price` as the ex-tax cost. // The JS back-calculation must compensate by multiplying the ex-tax rate // cost by this ratio before subtracting the incl-tax PostNL fee amounts. $tax_ratio = Utils::get_fee_total_price( 1.0 ); // PostNL threshold-based free shipping: a PostNL method is selected but its // rate was registered with cost = 0 (minimum_for_free_shipping reached). if ( ! $is_free_shipping && 0.0 === $raw_carrier_base_cost && $this->is_postnl_method_chosen() ) { $is_free_shipping = true; $carrier_base_cost = 0.0; } // **Letterbox is Eligible** if ( $letterbox ) { wp_send_json_success( array( 'message' => 'Eligible for letterbox.', 'show_container' => true, 'validated_address' => $validated_address, 'delivery_options' => $delivery_options['delivery_options'], 'dropoff_options' => array(), 'is_letterbox' => true, 'is_free_shipping' => $is_free_shipping, 'carrier_base_cost' => $carrier_base_cost, 'delivery_day_fee_display' => $delivery_day_fee_display, 'pickup_fee_display' => $pickup_fee_display, 'tax_ratio' => $tax_ratio, ), 200 ); wp_die(); } // Attempt to retrieve checkout data, delivery, and dropoff options try { $delivery_options_array = isset( $delivery_options['delivery_options'] ) ? $delivery_options['delivery_options'] : array(); $dropoff_options_array = isset( $dropoff_options['dropoff_options'] ) ? $dropoff_options['dropoff_options'] : array(); // Determine whether to show the container if ( empty( $delivery_options_array ) && empty( $dropoff_options_array ) ) { Utils::clear_postnl_checkout_session(); wp_send_json_success( array( 'message' => 'No delivery or dropoff options available.', 'show_container' => false, 'validated_address' => $validated_address, 'delivery_options' => array(), 'dropoff_options' => array(), 'is_free_shipping' => $is_free_shipping, 'is_letterbox' => false, ), 200 ); wp_die(); } // If there are delivery or dropoff options, show the container wp_send_json_success( array( 'message' => 'Data saved successfully.', 'show_container' => true, 'validated_address' => $validated_address, 'delivery_options' => $delivery_options_array, 'dropoff_options' => $dropoff_options_array, 'is_delivery_days_enabled' => $delivery_options['is_delivery_days_enabled'], 'is_free_shipping' => $is_free_shipping, 'is_letterbox' => false, 'carrier_base_cost' => $carrier_base_cost, 'delivery_day_fee_display' => $delivery_day_fee_display, 'pickup_fee_display' => $pickup_fee_display, 'tax_ratio' => $tax_ratio, ), 200 ); wp_die(); } catch ( \Exception $e ) { // If fetching delivery options fails. Utils::clear_postnl_checkout_session(); wp_send_json_success( array( 'message' => 'Failed to fetch delivery options.', 'show_container' => false, 'validated_address' => $validated_address, 'delivery_options' => array(), 'dropoff_options' => array(), ), 200 ); wp_die(); } } }