'mon', 2 => 'tue', 3 => 'wed', 4 => 'thu', 5 => 'fri', 6 => 'sat', 7 => 'sun', ); /** * SDK client factory. * * @var Client_Factory */ private $client_factory; /** * Plugin settings instance. * * @var Settings */ private $settings; /** * PostNL V4 API key used to authenticate SDK requests. * * @var string */ private $v4_key; /** * Number of look-ahead days to request, clamped to the V4 maximum. * * @var int */ private $number_of_days; /** * PSR-3 logger the failure path and the cache adapter report through. * * @var LoggerInterface */ private $logger; /** * Service constructor. * * The API key, the look-ahead day count and the logger are all required * rather than defaulted: the key has no getter on Settings to fall back to, a * defaulted day count would silently ignore the merchant's configured value * if a caller forgot to pass Settings::get_number_delivery_days(), and a * defaulted NullLogger would let a caller wire the service up with logging * silently switched off — the exact gap this parameter closes. * * The key is marked SensitiveParameter so PHP redacts it from stack traces, * matching Client_Factory::build(). * * @since 6.0.0 Added the required $logger parameter. * * @param Client_Factory $client_factory SDK client factory. * @param Settings $settings Plugin settings instance. * @param string $v4_key PostNL V4 API key. * @param int $number_of_days Look-ahead days, capped at the V4 max of 14 and floored at 1. * @param LoggerInterface $logger PSR-3 logger; wiring passes a Logger_Adapter. */ public function __construct( Client_Factory $client_factory, Settings $settings, #[\SensitiveParameter] string $v4_key, int $number_of_days, LoggerInterface $logger ) { $this->client_factory = $client_factory; $this->settings = $settings; $this->v4_key = $v4_key; $this->number_of_days = $this->clamp_days( $number_of_days ); $this->logger = $logger; } /** * Retrieve available delivery-day timeframes for a checkout address. * * @param array $post_data Checkout POST data (shipping_* address fields). * * @return array { * @type array $DeliveryOptions Legacy-shaped delivery options; see * Timeframe_Service_Interface. * } * * @throws \Exception Converted SDK error when the request fails. */ public function get_delivery_options( array $post_data ): array { // A merchant who disabled every drop-off day never hands parcels over; the // legacy path marks all days unavailable so PostNL returns nothing — mirror // that with an empty result instead of asking for undeliverable days. if ( $this->all_dropoff_days_disabled() ) { return array( 'DeliveryOptions' => array() ); } try { $request = $this->build_request( $post_data ); $client = $this->build_client(); $response = $client->timeframes()->forMultipleServices( $request ); return array( 'DeliveryOptions' => $this->map_response( $response->timeframes() ) ); } catch ( \Throwable $exception ) { // Exception_Converter returns a plugin-shaped \Exception; its message can // carry raw API text (field errors, upstream messages) — escape on output. $error = Exception_Converter::convert( $exception ); // The converted message is deliberately merchant-safe, and one of its // variants tells the reader to check these very logs, so the original SDK // failure has to be written here — nothing else reads getPrevious(). $this->logger->error( sprintf( 'V4 timeframe lookup failed for destination "%1$s": %2$s (cause: %3$s: %4$s)', $this->describe_destination( $post_data ), $error->getMessage(), get_class( $exception ), $exception->getMessage() ) ); throw $error; } } /** * Build the SDK request from the checkout address and shipping settings. * * Mirrors Legacy\Checkout\Item_Info: delivery days are always requested * (Base_Info hardcodes delivery_days_enabled), evening is added when enabled, * and morning stays a daytime sub-window (V4 has no separate morning service). * * @param array $post_data Checkout POST data. * * @return MultipleServicesTimeframeRequest */ protected function build_request( array $post_data ): MultipleServicesTimeframeRequest { return new MultipleServicesTimeframeRequest( handoverDate: $this->get_handover_date(), receiverAddress: $this->build_receiver_address( $post_data ), services: $this->build_services(), shipmentType: ShipmentType::Parcel, numberOfDays: $this->number_of_days, customerCode: (string) $this->settings->get_customer_code(), customerNumber: (string) $this->settings->get_customer_num() ); } /** * Build the receiver Address from the shipping_* POST fields. * * Matches Legacy\Checkout\Item_Info::convert_data_to_args(): the raw POST data * is first run through Address_Utils::set_post_data_address() to resolve the * billing→shipping fallback and house-number extraction, then address_1 is the * street and address_2 the house number. * * @param array $post_data Checkout POST data. * * @return Address */ protected function build_receiver_address( array $post_data ): Address { $post_data = Address_Utils::set_post_data_address( $post_data ); $country = isset( $post_data['shipping_country'] ) ? (string) $post_data['shipping_country'] : ''; $postcode = isset( $post_data['shipping_postcode'] ) ? str_replace( ' ', '', (string) $post_data['shipping_postcode'] ) : ''; return new Address( countryIso: Country::fromValue( $country ), houseNumber: isset( $post_data['shipping_address_2'] ) ? (string) $post_data['shipping_address_2'] : '', postalCode: $postcode, street: isset( $post_data['shipping_address_1'] ) ? (string) $post_data['shipping_address_1'] : '', city: isset( $post_data['shipping_city'] ) ? (string) $post_data['shipping_city'] : '' ); } /** * Short, log-safe description of the destination a failed lookup was for. * * Legacy Rest_API\Base::send_request() writes the entire request body — street, * house number and city included — to the same WooCommerce log. This keeps only * what identifies which lookup failed and lets support reproduce it: the country * and the postcode's leading area digits. The parts that would pin the entry to a * household are dropped, since an error line is written whether or not anyone is * debugging, while the SDK's own request logging (which does carry the address, * PII-redacted) is what a merchant turns on deliberately. * * @param array $post_data Checkout POST data. * * @return string e.g. 'NL 1234'; empty when the payload carried no address. */ private function describe_destination( array $post_data ): string { $post_data = Address_Utils::set_post_data_address( $post_data ); $country = isset( $post_data['shipping_country'] ) ? (string) $post_data['shipping_country'] : ''; $postcode = isset( $post_data['shipping_postcode'] ) ? str_replace( ' ', '', (string) $post_data['shipping_postcode'] ) : ''; return trim( $country . ' ' . substr( $postcode, 0, 4 ) ); } /** * Delivery-window services to request. * * @return DeliveryWindowService[] */ protected function build_services(): array { $services = array( DeliveryWindowService::Daytime ); if ( $this->is_evening_enabled() ) { $services[] = DeliveryWindowService::Evening; } return $services; } /** * Build the SDK client with the timeframe caching plugin attached. * * The CachingPlugin only caches responses whose URI contains '/timeframe/', * and its key prefix ('timeframe') keeps the Cache_Adapter allowlist happy so * both gates agree on what may be cached. * * The adapter is handed the logger so its allowlist-bypass warning can fire: a * keyPrefix that stops matching caches nothing at all, which is otherwise * indistinguishable from a permanently cold cache. * * @return \Postnl\Sdk\Client\PostnlClientInterface */ protected function build_client() { $caching_plugin = CachingPlugin::create( cache: new Cache_Adapter( $this->v4_key, $this->logger ), ttl: $this->cache_ttl(), allowedEndpoints: array( '/timeframe/' ), keyPrefix: 'timeframe' ); return $this->client_factory->build_with_plugins( $this->v4_key, (bool) $this->settings->is_sandbox(), $caching_plugin ); } /** * Map the SDK timeframe collection into the legacy DeliveryOptions shape. * * Available timeframes are grouped by delivery date; each window carries a * single legacy option code ('Daytime', 'Evening', or '08:00-12:00'). * * Windows map_option() reports as not offered are dropped, and a date left * without any window is dropped with them: an entry with an empty Timeframe * array would render a delivery date heading above an empty radio group. * * Delivery dates the merchant cannot reach from an enabled drop-off day are * dropped here too — see is_reachable_delivery_date(). Filtering in the * mapping rather than the request keeps cached responses consistent: the SDK * CachingPlugin caches the raw HTTP response inside the transport, so this * mapping runs on cache hits exactly as it does on live responses. * * @param TimeframeMultipleServicesCollection $collection SDK timeframe collection. * * @return array}> */ protected function map_response( TimeframeMultipleServicesCollection $collection ): array { // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Third-party SDK DTO properties are camelCase. $by_date = array(); $dropoff_days = $this->get_dropoff_days(); foreach ( $collection->filterAvailable()->all() as $timeframe ) { if ( null === $timeframe->deliveryDate || null === $timeframe->timeFrame ) { continue; } if ( ! $this->is_reachable_delivery_date( $timeframe->deliveryDate, $dropoff_days ) ) { continue; } $option = $this->map_option( $timeframe ); // Skipped before the date entry is created, so a date whose every window // was dropped never enters the result in the first place. if ( null === $option ) { continue; } $date = $timeframe->deliveryDate; if ( ! isset( $by_date[ $date ] ) ) { $by_date[ $date ] = array( 'DeliveryDate' => $date, 'Timeframe' => array(), ); } $by_date[ $date ]['Timeframe'][] = array( 'From' => (string) $timeframe->timeFrame->from, 'To' => (string) $timeframe->timeFrame->until, 'Options' => array( $option ), ); } return array_values( $by_date ); } /** * Translate a V4 timeframe into the legacy option code the checkout expects. * * Evening and morning windows are only offered when the merchant enabled that * delivery option; otherwise the window is dropped rather than relabelled to * 'Daytime'. Relabelling would put a window on the checkout that the legacy * path never showed: a second, identical 'Daytime' radio which — being * fee-free and first in the list — becomes the preselected default that gets * saved on the order (Frontend\Container::get_default_value()). * * The classification is deliberately separate from the toggles: PostNL returns * late windows under the 'daytime' service and TimeSlot::isEvening() only * checks from >= 17:00, so a 17:00-21:00 daytime window is an evening window * here and must disappear when evening delivery is off. A genuinely plain * window (e.g. 09:00-18:00) stays 'Daytime' whatever the toggles say. * * @param \Postnl\Sdk\ResponseData\V4\TimeFrame $timeframe SDK timeframe entry. * * @return string|null Legacy option code, or null when the window is not offered. */ protected function map_option( $timeframe ): ?string { $service = is_string( $timeframe->service ) ? strtolower( $timeframe->service ) : ''; $slot = $timeframe->timeFrame; if ( DeliveryWindowService::Evening->value === $service || ( null !== $slot && $slot->isEvening() ) ) { return $this->is_evening_enabled() ? 'Evening' : null; } if ( null !== $slot && $slot->isMorning() ) { return $this->is_morning_enabled() ? '08:00-12:00' : null; } return 'Daytime'; } // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase /** * Handover date (ISO 8601 date) the SDK computes delivery days from. * * The legacy checkout call sends OrderDate, ShippingDuration, and per-day * CutOffTimes and lets PostNL walk the calendar to the first shippable day; * the V4 request only accepts the resulting handoverDate, so that walk * happens here: an order placed after the cut-off time hands over a day * later, each transit day beyond the first adds a preparation day, and the * handover then lands on the next enabled drop-off day. * * @return string */ protected function get_handover_date(): string { $now = $this->now(); $handover = $now; if ( $now->format( 'H:i' ) > $this->get_cut_off_time() ) { $handover = $handover->modify( '+1 day' ); } $extra_days = $this->get_shipping_duration() - 1; if ( $extra_days > 0 ) { $handover = $handover->modify( '+' . $extra_days . ' days' ); } $dropoff_days = $this->get_dropoff_days(); if ( ! empty( $dropoff_days ) ) { $attempts = 0; while ( $attempts < 6 && ! in_array( self::WEEKDAYS[ (int) $handover->format( 'N' ) ], $dropoff_days, true ) ) { $handover = $handover->modify( '+1 day' ); ++$attempts; } } return $handover->format( 'Y-m-d' ); } /** * Whether a delivery date can be reached from an enabled drop-off day. * * The legacy checkout call sent one CutOffTimes entry per excluded drop-off * day (Legacy\Checkout\Client::get_cutoff_times()) and PostNL withheld every * delivery date whose handover fell on one. The V4 request has no equivalent * field — it carries a single handoverDate — so get_handover_date() can only * place the *first* handover on a valid day; without this filter every later * date PostNL returns ignores drop-off days entirely, and a merchant shipping * Mondays and Thursdays would offer a Wednesday delivery that needs a Tuesday * handover they never do. * * The handover a date implies comes from get_handover_date()'s own model: an * order with transit time t hands over at order + (t - 1) preparation days and * the Transit Time setting promises delivery at order + t, so PostNL delivers * the day after handover and date D is reachable only when D - 1 day is an * enabled drop-off day. * * With every day enabled this is a no-op; with none enabled * get_delivery_options() has already short-circuited before mapping. * * @param string $date Delivery date in the d-m-Y format PostNL returns, e.g. '14-07-2026'. * @param string[] $dropoff_days Enabled drop-off weekday keys. * * @return bool */ private function is_reachable_delivery_date( string $date, array $dropoff_days ): bool { $delivery = \DateTimeImmutable::createFromFormat( '!d-m-Y', $date ); // An unreadable date is kept rather than filtered away: if PostNL ever // returns another format, dropping every date would leave the customer an // empty delivery-day widget with nothing to explain it. if ( false === $delivery ) { return true; } $handover = $delivery->modify( '-1 day' ); return in_array( self::WEEKDAYS[ (int) $handover->format( 'N' ) ], $dropoff_days, true ); } /** * Current site-timezone datetime; a seam for deterministic tests. * * @return \DateTimeImmutable */ protected function now(): \DateTimeImmutable { return current_datetime(); } /** * Cut-off time (HH:MM) after which an order hands over the next day. * * Falls back to the Legacy\Checkout\Item_Info default when the setting is * missing or malformed, instead of failing the checkout lookup. * * @return string */ private function get_cut_off_time(): string { $cut_off = (string) $this->settings->get_cut_off_time(); if ( 1 === preg_match( '/^(?:[01][0-9]|2[0-4]):[0-5][0-9]$/', $cut_off ) ) { return $cut_off; } return self::DEFAULT_CUT_OFF_TIME; } /** * Shipping duration in days (the legacy ShippingDuration / transit_time setting), minimum 1. * * @return int */ private function get_shipping_duration(): int { return max( 1, (int) $this->settings->get_transit_time() ); } /** * Enabled drop-off weekday keys ('mon' … 'sun'). * * @return string[] */ private function get_dropoff_days(): array { return $this->settings->get_dropoff_days(); } /** * Whether the merchant disabled every drop-off day. * * @return bool */ private function all_dropoff_days_disabled(): bool { return array() === $this->get_dropoff_days(); } /** * TTL, in seconds, for cached timeframe responses. * * Reads the same filter as Cache_Adapter so both agree, and never returns a * value <= 0 since CachingPlugin rejects one. * * @return int */ protected function cache_ttl(): int { /** * Filters the TTL, in seconds, for cached V4 timeframe/locations responses. * * @since 6.0.0 * * @param int $ttl Default Cache_Adapter::DEFAULT_TTL (600 seconds). */ $ttl = (int) apply_filters( 'postnl_v4_cache_ttl', Cache_Adapter::DEFAULT_TTL ); return $ttl > 0 ? $ttl : Cache_Adapter::DEFAULT_TTL; } /** * Whether evening delivery is enabled in settings. * * @return bool */ private function is_evening_enabled(): bool { return (bool) $this->settings->is_evening_delivery_enabled(); } /** * Whether morning delivery is enabled in settings. * * @return bool */ private function is_morning_enabled(): bool { return (bool) $this->settings->is_morning_delivery_enabled(); } /** * Clamp the requested look-ahead days to the range the V4 endpoint accepts. * * @param int $days Requested number of days. * * @return int */ private function clamp_days( int $days ): int { if ( $days < 1 ) { return 1; } return min( $days, self::V4_MAX_DELIVERY_DAYS ); } }