| 1 |
<?php |
| 2 |
/** |
| 3 |
* Class Rest_API\V4\Timeframe\Service file. |
| 4 |
* |
| 5 |
* @package PostNLWooCommerce\Rest_API\V4\Timeframe |
| 6 |
*/ |
| 7 |
|
| 8 |
declare( strict_types = 1 ); |
| 9 |
|
| 10 |
namespace PostNLWooCommerce\Rest_API\V4\Timeframe; |
| 11 |
|
| 12 |
use Postnl\Sdk\Enums\Payload\Country; |
| 13 |
use Postnl\Sdk\Enums\Payload\DeliveryWindowService; |
| 14 |
use Postnl\Sdk\Enums\Payload\ShipmentType; |
| 15 |
use Postnl\Sdk\RequestData\V4\Address; |
| 16 |
use Postnl\Sdk\Service\Timeframes\V4\Request\MultipleServicesTimeframeRequest; |
| 17 |
use Postnl\Sdk\Service\Timeframes\V4\Response\TimeframeMultipleServicesCollection; |
| 18 |
use Postnl\Sdk\Transport\Cache\CachingPlugin; |
| 19 |
use PostNLWooCommerce\Address_Utils; |
| 20 |
use PostNLWooCommerce\Rest_API\Contracts\Timeframe_Service_Interface; |
| 21 |
use PostNLWooCommerce\Rest_API\SDK\Cache_Adapter; |
| 22 |
use PostNLWooCommerce\Rest_API\SDK\Client_Factory; |
| 23 |
use PostNLWooCommerce\Rest_API\SDK\Exception_Converter; |
| 24 |
use PostNLWooCommerce\Shipping_Method\Settings; |
| 25 |
use Psr\Log\LoggerInterface; |
| 26 |
|
| 27 |
if ( ! defined( 'ABSPATH' ) ) { |
| 28 |
exit; |
| 29 |
} |
| 30 |
|
| 31 |
/** |
| 32 |
* Class Service |
| 33 |
* |
| 34 |
* V4 SDK-backed delivery-day (timeframe) lookup. Mirrors the timeframe half of |
| 35 |
* Legacy\Checkout\{Client,Item_Info}: it builds the request from the checkout |
| 36 |
* address plus shipping settings, calls the SDK timeframes() endpoint, and maps |
| 37 |
* the response back into the exact DeliveryOptions shape Frontend\Container |
| 38 |
* consumes (see Frontend\Container::get_checkout_data() and get_default_value()), |
| 39 |
* so callers cannot tell V4 from the legacy /shipment/v1/checkout path. |
| 40 |
* |
| 41 |
* Timeframe responses are the same on every checkout pageload for a given |
| 42 |
* address, so the request is routed through the SDK CachingPlugin (backed by |
| 43 |
* Cache_Adapter / WP transients) with only /timeframe/ allowlisted. |
| 44 |
* |
| 45 |
* The PSR-3 logger is required, not optional: it is where a failed lookup's real |
| 46 |
* cause survives (Exception_Converter hands the merchant a safe message and keeps |
| 47 |
* the SDK's own only as the previous exception), and it is what Cache_Adapter |
| 48 |
* reports a mis-wired cache through. Wiring passes a Logger_Adapter built on |
| 49 |
* Main::get_logger(), so V4 entries land in the same WooCommerce log as the |
| 50 |
* legacy path and honour the same "enable logging" setting. |
| 51 |
* |
| 52 |
* @since 6.0.0 |
| 53 |
* @package PostNLWooCommerce\Rest_API\V4\Timeframe |
| 54 |
*/ |
| 55 |
class Service implements Timeframe_Service_Interface { |
| 56 |
|
| 57 |
/** |
| 58 |
* Maximum number of look-ahead days the V4 timeframe endpoint accepts. |
| 59 |
*/ |
| 60 |
private const V4_MAX_DELIVERY_DAYS = 14; |
| 61 |
|
| 62 |
/** |
| 63 |
* Cut-off time used when the merchant has not configured one, matching the |
| 64 |
* Legacy\Checkout\Item_Info default. |
| 65 |
*/ |
| 66 |
private const DEFAULT_CUT_OFF_TIME = '23:00'; |
| 67 |
|
| 68 |
/** |
| 69 |
* Weekday keys by ISO-8601 day number, as Settings::get_dropoff_days() returns them. |
| 70 |
*/ |
| 71 |
private const WEEKDAYS = array( |
| 72 |
1 => 'mon', |
| 73 |
2 => 'tue', |
| 74 |
3 => 'wed', |
| 75 |
4 => 'thu', |
| 76 |
5 => 'fri', |
| 77 |
6 => 'sat', |
| 78 |
7 => 'sun', |
| 79 |
); |
| 80 |
|
| 81 |
/** |
| 82 |
* SDK client factory. |
| 83 |
* |
| 84 |
* @var Client_Factory |
| 85 |
*/ |
| 86 |
private $client_factory; |
| 87 |
|
| 88 |
/** |
| 89 |
* Plugin settings instance. |
| 90 |
* |
| 91 |
* @var Settings |
| 92 |
*/ |
| 93 |
private $settings; |
| 94 |
|
| 95 |
/** |
| 96 |
* PostNL V4 API key used to authenticate SDK requests. |
| 97 |
* |
| 98 |
* @var string |
| 99 |
*/ |
| 100 |
private $v4_key; |
| 101 |
|
| 102 |
/** |
| 103 |
* Number of look-ahead days to request, clamped to the V4 maximum. |
| 104 |
* |
| 105 |
* @var int |
| 106 |
*/ |
| 107 |
private $number_of_days; |
| 108 |
|
| 109 |
/** |
| 110 |
* PSR-3 logger the failure path and the cache adapter report through. |
| 111 |
* |
| 112 |
* @var LoggerInterface |
| 113 |
*/ |
| 114 |
private $logger; |
| 115 |
|
| 116 |
/** |
| 117 |
* Service constructor. |
| 118 |
* |
| 119 |
* The API key, the look-ahead day count and the logger are all required |
| 120 |
* rather than defaulted: the key has no getter on Settings to fall back to, a |
| 121 |
* defaulted day count would silently ignore the merchant's configured value |
| 122 |
* if a caller forgot to pass Settings::get_number_delivery_days(), and a |
| 123 |
* defaulted NullLogger would let a caller wire the service up with logging |
| 124 |
* silently switched off — the exact gap this parameter closes. |
| 125 |
* |
| 126 |
* The key is marked SensitiveParameter so PHP redacts it from stack traces, |
| 127 |
* matching Client_Factory::build(). |
| 128 |
* |
| 129 |
* @since 6.0.0 Added the required $logger parameter. |
| 130 |
* |
| 131 |
* @param Client_Factory $client_factory SDK client factory. |
| 132 |
* @param Settings $settings Plugin settings instance. |
| 133 |
* @param string $v4_key PostNL V4 API key. |
| 134 |
* @param int $number_of_days Look-ahead days, capped at the V4 max of 14 and floored at 1. |
| 135 |
* @param LoggerInterface $logger PSR-3 logger; wiring passes a Logger_Adapter. |
| 136 |
*/ |
| 137 |
public function __construct( |
| 138 |
Client_Factory $client_factory, |
| 139 |
Settings $settings, |
| 140 |
#[\SensitiveParameter] |
| 141 |
string $v4_key, |
| 142 |
int $number_of_days, |
| 143 |
LoggerInterface $logger |
| 144 |
) { |
| 145 |
$this->client_factory = $client_factory; |
| 146 |
$this->settings = $settings; |
| 147 |
$this->v4_key = $v4_key; |
| 148 |
$this->number_of_days = $this->clamp_days( $number_of_days ); |
| 149 |
$this->logger = $logger; |
| 150 |
} |
| 151 |
|
| 152 |
/** |
| 153 |
* Retrieve available delivery-day timeframes for a checkout address. |
| 154 |
* |
| 155 |
* @param array $post_data Checkout POST data (shipping_* address fields). |
| 156 |
* |
| 157 |
* @return array { |
| 158 |
* @type array $DeliveryOptions Legacy-shaped delivery options; see |
| 159 |
* Timeframe_Service_Interface. |
| 160 |
* } |
| 161 |
* |
| 162 |
* @throws \Exception Converted SDK error when the request fails. |
| 163 |
*/ |
| 164 |
public function get_delivery_options( array $post_data ): array { |
| 165 |
// A merchant who disabled every drop-off day never hands parcels over; the |
| 166 |
// legacy path marks all days unavailable so PostNL returns nothing — mirror |
| 167 |
// that with an empty result instead of asking for undeliverable days. |
| 168 |
if ( $this->all_dropoff_days_disabled() ) { |
| 169 |
return array( 'DeliveryOptions' => array() ); |
| 170 |
} |
| 171 |
|
| 172 |
try { |
| 173 |
$request = $this->build_request( $post_data ); |
| 174 |
$client = $this->build_client(); |
| 175 |
$response = $client->timeframes()->forMultipleServices( $request ); |
| 176 |
|
| 177 |
return array( 'DeliveryOptions' => $this->map_response( $response->timeframes() ) ); |
| 178 |
} catch ( \Throwable $exception ) { |
| 179 |
// Exception_Converter returns a plugin-shaped \Exception; its message can |
| 180 |
// carry raw API text (field errors, upstream messages) — escape on output. |
| 181 |
$error = Exception_Converter::convert( $exception ); |
| 182 |
|
| 183 |
// The converted message is deliberately merchant-safe, and one of its |
| 184 |
// variants tells the reader to check these very logs, so the original SDK |
| 185 |
// failure has to be written here — nothing else reads getPrevious(). |
| 186 |
$this->logger->error( |
| 187 |
sprintf( |
| 188 |
'V4 timeframe lookup failed for destination "%1$s": %2$s (cause: %3$s: %4$s)', |
| 189 |
$this->describe_destination( $post_data ), |
| 190 |
$error->getMessage(), |
| 191 |
get_class( $exception ), |
| 192 |
$exception->getMessage() |
| 193 |
) |
| 194 |
); |
| 195 |
|
| 196 |
throw $error; |
| 197 |
} |
| 198 |
} |
| 199 |
|
| 200 |
/** |
| 201 |
* Build the SDK request from the checkout address and shipping settings. |
| 202 |
* |
| 203 |
* Mirrors Legacy\Checkout\Item_Info: delivery days are always requested |
| 204 |
* (Base_Info hardcodes delivery_days_enabled), evening is added when enabled, |
| 205 |
* and morning stays a daytime sub-window (V4 has no separate morning service). |
| 206 |
* |
| 207 |
* @param array $post_data Checkout POST data. |
| 208 |
* |
| 209 |
* @return MultipleServicesTimeframeRequest |
| 210 |
*/ |
| 211 |
protected function build_request( array $post_data ): MultipleServicesTimeframeRequest { |
| 212 |
return new MultipleServicesTimeframeRequest( |
| 213 |
handoverDate: $this->get_handover_date(), |
| 214 |
receiverAddress: $this->build_receiver_address( $post_data ), |
| 215 |
services: $this->build_services(), |
| 216 |
shipmentType: ShipmentType::Parcel, |
| 217 |
numberOfDays: $this->number_of_days, |
| 218 |
customerCode: (string) $this->settings->get_customer_code(), |
| 219 |
customerNumber: (string) $this->settings->get_customer_num() |
| 220 |
); |
| 221 |
} |
| 222 |
|
| 223 |
/** |
| 224 |
* Build the receiver Address from the shipping_* POST fields. |
| 225 |
* |
| 226 |
* Matches Legacy\Checkout\Item_Info::convert_data_to_args(): the raw POST data |
| 227 |
* is first run through Address_Utils::set_post_data_address() to resolve the |
| 228 |
* billing→shipping fallback and house-number extraction, then address_1 is the |
| 229 |
* street and address_2 the house number. |
| 230 |
* |
| 231 |
* @param array $post_data Checkout POST data. |
| 232 |
* |
| 233 |
* @return Address |
| 234 |
*/ |
| 235 |
protected function build_receiver_address( array $post_data ): Address { |
| 236 |
$post_data = Address_Utils::set_post_data_address( $post_data ); |
| 237 |
|
| 238 |
$country = isset( $post_data['shipping_country'] ) ? (string) $post_data['shipping_country'] : ''; |
| 239 |
$postcode = isset( $post_data['shipping_postcode'] ) ? str_replace( ' ', '', (string) $post_data['shipping_postcode'] ) : ''; |
| 240 |
|
| 241 |
return new Address( |
| 242 |
countryIso: Country::fromValue( $country ), |
| 243 |
houseNumber: isset( $post_data['shipping_address_2'] ) ? (string) $post_data['shipping_address_2'] : '', |
| 244 |
postalCode: $postcode, |
| 245 |
street: isset( $post_data['shipping_address_1'] ) ? (string) $post_data['shipping_address_1'] : '', |
| 246 |
city: isset( $post_data['shipping_city'] ) ? (string) $post_data['shipping_city'] : '' |
| 247 |
); |
| 248 |
} |
| 249 |
|
| 250 |
/** |
| 251 |
* Short, log-safe description of the destination a failed lookup was for. |
| 252 |
* |
| 253 |
* Legacy Rest_API\Base::send_request() writes the entire request body — street, |
| 254 |
* house number and city included — to the same WooCommerce log. This keeps only |
| 255 |
* what identifies which lookup failed and lets support reproduce it: the country |
| 256 |
* and the postcode's leading area digits. The parts that would pin the entry to a |
| 257 |
* household are dropped, since an error line is written whether or not anyone is |
| 258 |
* debugging, while the SDK's own request logging (which does carry the address, |
| 259 |
* PII-redacted) is what a merchant turns on deliberately. |
| 260 |
* |
| 261 |
* @param array $post_data Checkout POST data. |
| 262 |
* |
| 263 |
* @return string e.g. 'NL 1234'; empty when the payload carried no address. |
| 264 |
*/ |
| 265 |
private function describe_destination( array $post_data ): string { |
| 266 |
$post_data = Address_Utils::set_post_data_address( $post_data ); |
| 267 |
|
| 268 |
$country = isset( $post_data['shipping_country'] ) ? (string) $post_data['shipping_country'] : ''; |
| 269 |
$postcode = isset( $post_data['shipping_postcode'] ) ? str_replace( ' ', '', (string) $post_data['shipping_postcode'] ) : ''; |
| 270 |
|
| 271 |
return trim( $country . ' ' . substr( $postcode, 0, 4 ) ); |
| 272 |
} |
| 273 |
|
| 274 |
/** |
| 275 |
* Delivery-window services to request. |
| 276 |
* |
| 277 |
* @return DeliveryWindowService[] |
| 278 |
*/ |
| 279 |
protected function build_services(): array { |
| 280 |
$services = array( DeliveryWindowService::Daytime ); |
| 281 |
|
| 282 |
if ( $this->is_evening_enabled() ) { |
| 283 |
$services[] = DeliveryWindowService::Evening; |
| 284 |
} |
| 285 |
|
| 286 |
return $services; |
| 287 |
} |
| 288 |
|
| 289 |
/** |
| 290 |
* Build the SDK client with the timeframe caching plugin attached. |
| 291 |
* |
| 292 |
* The CachingPlugin only caches responses whose URI contains '/timeframe/', |
| 293 |
* and its key prefix ('timeframe') keeps the Cache_Adapter allowlist happy so |
| 294 |
* both gates agree on what may be cached. |
| 295 |
* |
| 296 |
* The adapter is handed the logger so its allowlist-bypass warning can fire: a |
| 297 |
* keyPrefix that stops matching caches nothing at all, which is otherwise |
| 298 |
* indistinguishable from a permanently cold cache. |
| 299 |
* |
| 300 |
* @return \Postnl\Sdk\Client\PostnlClientInterface |
| 301 |
*/ |
| 302 |
protected function build_client() { |
| 303 |
$caching_plugin = CachingPlugin::create( |
| 304 |
cache: new Cache_Adapter( $this->v4_key, $this->logger ), |
| 305 |
ttl: $this->cache_ttl(), |
| 306 |
allowedEndpoints: array( '/timeframe/' ), |
| 307 |
keyPrefix: 'timeframe' |
| 308 |
); |
| 309 |
|
| 310 |
return $this->client_factory->build_with_plugins( $this->v4_key, (bool) $this->settings->is_sandbox(), $caching_plugin ); |
| 311 |
} |
| 312 |
|
| 313 |
/** |
| 314 |
* Map the SDK timeframe collection into the legacy DeliveryOptions shape. |
| 315 |
* |
| 316 |
* Available timeframes are grouped by delivery date; each window carries a |
| 317 |
* single legacy option code ('Daytime', 'Evening', or '08:00-12:00'). |
| 318 |
* |
| 319 |
* Windows map_option() reports as not offered are dropped, and a date left |
| 320 |
* without any window is dropped with them: an entry with an empty Timeframe |
| 321 |
* array would render a delivery date heading above an empty radio group. |
| 322 |
* |
| 323 |
* Delivery dates the merchant cannot reach from an enabled drop-off day are |
| 324 |
* dropped here too — see is_reachable_delivery_date(). Filtering in the |
| 325 |
* mapping rather than the request keeps cached responses consistent: the SDK |
| 326 |
* CachingPlugin caches the raw HTTP response inside the transport, so this |
| 327 |
* mapping runs on cache hits exactly as it does on live responses. |
| 328 |
* |
| 329 |
* @param TimeframeMultipleServicesCollection $collection SDK timeframe collection. |
| 330 |
* |
| 331 |
* @return array<int, array{DeliveryDate: string, Timeframe: array<int, array{From: string, To: string, Options: string[]}>}> |
| 332 |
*/ |
| 333 |
protected function map_response( TimeframeMultipleServicesCollection $collection ): array { |
| 334 |
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Third-party SDK DTO properties are camelCase. |
| 335 |
$by_date = array(); |
| 336 |
$dropoff_days = $this->get_dropoff_days(); |
| 337 |
|
| 338 |
foreach ( $collection->filterAvailable()->all() as $timeframe ) { |
| 339 |
if ( null === $timeframe->deliveryDate || null === $timeframe->timeFrame ) { |
| 340 |
continue; |
| 341 |
} |
| 342 |
|
| 343 |
if ( ! $this->is_reachable_delivery_date( $timeframe->deliveryDate, $dropoff_days ) ) { |
| 344 |
continue; |
| 345 |
} |
| 346 |
|
| 347 |
$option = $this->map_option( $timeframe ); |
| 348 |
|
| 349 |
// Skipped before the date entry is created, so a date whose every window |
| 350 |
// was dropped never enters the result in the first place. |
| 351 |
if ( null === $option ) { |
| 352 |
continue; |
| 353 |
} |
| 354 |
|
| 355 |
$date = $timeframe->deliveryDate; |
| 356 |
|
| 357 |
if ( ! isset( $by_date[ $date ] ) ) { |
| 358 |
$by_date[ $date ] = array( |
| 359 |
'DeliveryDate' => $date, |
| 360 |
'Timeframe' => array(), |
| 361 |
); |
| 362 |
} |
| 363 |
|
| 364 |
$by_date[ $date ]['Timeframe'][] = array( |
| 365 |
'From' => (string) $timeframe->timeFrame->from, |
| 366 |
'To' => (string) $timeframe->timeFrame->until, |
| 367 |
'Options' => array( $option ), |
| 368 |
); |
| 369 |
} |
| 370 |
|
| 371 |
return array_values( $by_date ); |
| 372 |
} |
| 373 |
|
| 374 |
/** |
| 375 |
* Translate a V4 timeframe into the legacy option code the checkout expects. |
| 376 |
* |
| 377 |
* Evening and morning windows are only offered when the merchant enabled that |
| 378 |
* delivery option; otherwise the window is dropped rather than relabelled to |
| 379 |
* 'Daytime'. Relabelling would put a window on the checkout that the legacy |
| 380 |
* path never showed: a second, identical 'Daytime' radio which — being |
| 381 |
* fee-free and first in the list — becomes the preselected default that gets |
| 382 |
* saved on the order (Frontend\Container::get_default_value()). |
| 383 |
* |
| 384 |
* The classification is deliberately separate from the toggles: PostNL returns |
| 385 |
* late windows under the 'daytime' service and TimeSlot::isEvening() only |
| 386 |
* checks from >= 17:00, so a 17:00-21:00 daytime window is an evening window |
| 387 |
* here and must disappear when evening delivery is off. A genuinely plain |
| 388 |
* window (e.g. 09:00-18:00) stays 'Daytime' whatever the toggles say. |
| 389 |
* |
| 390 |
* @param \Postnl\Sdk\ResponseData\V4\TimeFrame $timeframe SDK timeframe entry. |
| 391 |
* |
| 392 |
* @return string|null Legacy option code, or null when the window is not offered. |
| 393 |
*/ |
| 394 |
protected function map_option( $timeframe ): ?string { |
| 395 |
$service = is_string( $timeframe->service ) ? strtolower( $timeframe->service ) : ''; |
| 396 |
$slot = $timeframe->timeFrame; |
| 397 |
|
| 398 |
if ( DeliveryWindowService::Evening->value === $service || ( null !== $slot && $slot->isEvening() ) ) { |
| 399 |
return $this->is_evening_enabled() ? 'Evening' : null; |
| 400 |
} |
| 401 |
|
| 402 |
if ( null !== $slot && $slot->isMorning() ) { |
| 403 |
return $this->is_morning_enabled() ? '08:00-12:00' : null; |
| 404 |
} |
| 405 |
|
| 406 |
return 'Daytime'; |
| 407 |
} |
| 408 |
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 409 |
|
| 410 |
/** |
| 411 |
* Handover date (ISO 8601 date) the SDK computes delivery days from. |
| 412 |
* |
| 413 |
* The legacy checkout call sends OrderDate, ShippingDuration, and per-day |
| 414 |
* CutOffTimes and lets PostNL walk the calendar to the first shippable day; |
| 415 |
* the V4 request only accepts the resulting handoverDate, so that walk |
| 416 |
* happens here: an order placed after the cut-off time hands over a day |
| 417 |
* later, each transit day beyond the first adds a preparation day, and the |
| 418 |
* handover then lands on the next enabled drop-off day. |
| 419 |
* |
| 420 |
* @return string |
| 421 |
*/ |
| 422 |
protected function get_handover_date(): string { |
| 423 |
$now = $this->now(); |
| 424 |
$handover = $now; |
| 425 |
|
| 426 |
if ( $now->format( 'H:i' ) > $this->get_cut_off_time() ) { |
| 427 |
$handover = $handover->modify( '+1 day' ); |
| 428 |
} |
| 429 |
|
| 430 |
$extra_days = $this->get_shipping_duration() - 1; |
| 431 |
if ( $extra_days > 0 ) { |
| 432 |
$handover = $handover->modify( '+' . $extra_days . ' days' ); |
| 433 |
} |
| 434 |
|
| 435 |
$dropoff_days = $this->get_dropoff_days(); |
| 436 |
if ( ! empty( $dropoff_days ) ) { |
| 437 |
$attempts = 0; |
| 438 |
while ( $attempts < 6 && ! in_array( self::WEEKDAYS[ (int) $handover->format( 'N' ) ], $dropoff_days, true ) ) { |
| 439 |
$handover = $handover->modify( '+1 day' ); |
| 440 |
++$attempts; |
| 441 |
} |
| 442 |
} |
| 443 |
|
| 444 |
return $handover->format( 'Y-m-d' ); |
| 445 |
} |
| 446 |
|
| 447 |
/** |
| 448 |
* Whether a delivery date can be reached from an enabled drop-off day. |
| 449 |
* |
| 450 |
* The legacy checkout call sent one CutOffTimes entry per excluded drop-off |
| 451 |
* day (Legacy\Checkout\Client::get_cutoff_times()) and PostNL withheld every |
| 452 |
* delivery date whose handover fell on one. The V4 request has no equivalent |
| 453 |
* field — it carries a single handoverDate — so get_handover_date() can only |
| 454 |
* place the *first* handover on a valid day; without this filter every later |
| 455 |
* date PostNL returns ignores drop-off days entirely, and a merchant shipping |
| 456 |
* Mondays and Thursdays would offer a Wednesday delivery that needs a Tuesday |
| 457 |
* handover they never do. |
| 458 |
* |
| 459 |
* The handover a date implies comes from get_handover_date()'s own model: an |
| 460 |
* order with transit time t hands over at order + (t - 1) preparation days and |
| 461 |
* the Transit Time setting promises delivery at order + t, so PostNL delivers |
| 462 |
* the day after handover and date D is reachable only when D - 1 day is an |
| 463 |
* enabled drop-off day. |
| 464 |
* |
| 465 |
* With every day enabled this is a no-op; with none enabled |
| 466 |
* get_delivery_options() has already short-circuited before mapping. |
| 467 |
* |
| 468 |
* @param string $date Delivery date in the d-m-Y format PostNL returns, e.g. '14-07-2026'. |
| 469 |
* @param string[] $dropoff_days Enabled drop-off weekday keys. |
| 470 |
* |
| 471 |
* @return bool |
| 472 |
*/ |
| 473 |
private function is_reachable_delivery_date( string $date, array $dropoff_days ): bool { |
| 474 |
$delivery = \DateTimeImmutable::createFromFormat( '!d-m-Y', $date ); |
| 475 |
|
| 476 |
// An unreadable date is kept rather than filtered away: if PostNL ever |
| 477 |
// returns another format, dropping every date would leave the customer an |
| 478 |
// empty delivery-day widget with nothing to explain it. |
| 479 |
if ( false === $delivery ) { |
| 480 |
return true; |
| 481 |
} |
| 482 |
|
| 483 |
$handover = $delivery->modify( '-1 day' ); |
| 484 |
|
| 485 |
return in_array( self::WEEKDAYS[ (int) $handover->format( 'N' ) ], $dropoff_days, true ); |
| 486 |
} |
| 487 |
|
| 488 |
/** |
| 489 |
* Current site-timezone datetime; a seam for deterministic tests. |
| 490 |
* |
| 491 |
* @return \DateTimeImmutable |
| 492 |
*/ |
| 493 |
protected function now(): \DateTimeImmutable { |
| 494 |
return current_datetime(); |
| 495 |
} |
| 496 |
|
| 497 |
/** |
| 498 |
* Cut-off time (HH:MM) after which an order hands over the next day. |
| 499 |
* |
| 500 |
* Falls back to the Legacy\Checkout\Item_Info default when the setting is |
| 501 |
* missing or malformed, instead of failing the checkout lookup. |
| 502 |
* |
| 503 |
* @return string |
| 504 |
*/ |
| 505 |
private function get_cut_off_time(): string { |
| 506 |
$cut_off = (string) $this->settings->get_cut_off_time(); |
| 507 |
|
| 508 |
if ( 1 === preg_match( '/^(?:[01][0-9]|2[0-4]):[0-5][0-9]$/', $cut_off ) ) { |
| 509 |
return $cut_off; |
| 510 |
} |
| 511 |
|
| 512 |
return self::DEFAULT_CUT_OFF_TIME; |
| 513 |
} |
| 514 |
|
| 515 |
/** |
| 516 |
* Shipping duration in days (the legacy ShippingDuration / transit_time setting), minimum 1. |
| 517 |
* |
| 518 |
* @return int |
| 519 |
*/ |
| 520 |
private function get_shipping_duration(): int { |
| 521 |
return max( 1, (int) $this->settings->get_transit_time() ); |
| 522 |
} |
| 523 |
|
| 524 |
/** |
| 525 |
* Enabled drop-off weekday keys ('mon' … 'sun'). |
| 526 |
* |
| 527 |
* @return string[] |
| 528 |
*/ |
| 529 |
private function get_dropoff_days(): array { |
| 530 |
return $this->settings->get_dropoff_days(); |
| 531 |
} |
| 532 |
|
| 533 |
/** |
| 534 |
* Whether the merchant disabled every drop-off day. |
| 535 |
* |
| 536 |
* @return bool |
| 537 |
*/ |
| 538 |
private function all_dropoff_days_disabled(): bool { |
| 539 |
return array() === $this->get_dropoff_days(); |
| 540 |
} |
| 541 |
|
| 542 |
/** |
| 543 |
* TTL, in seconds, for cached timeframe responses. |
| 544 |
* |
| 545 |
* Reads the same filter as Cache_Adapter so both agree, and never returns a |
| 546 |
* value <= 0 since CachingPlugin rejects one. |
| 547 |
* |
| 548 |
* @return int |
| 549 |
*/ |
| 550 |
protected function cache_ttl(): int { |
| 551 |
/** |
| 552 |
* Filters the TTL, in seconds, for cached V4 timeframe/locations responses. |
| 553 |
* |
| 554 |
* @since 6.0.0 |
| 555 |
* |
| 556 |
* @param int $ttl Default Cache_Adapter::DEFAULT_TTL (600 seconds). |
| 557 |
*/ |
| 558 |
$ttl = (int) apply_filters( 'postnl_v4_cache_ttl', Cache_Adapter::DEFAULT_TTL ); |
| 559 |
|
| 560 |
return $ttl > 0 ? $ttl : Cache_Adapter::DEFAULT_TTL; |
| 561 |
} |
| 562 |
|
| 563 |
/** |
| 564 |
* Whether evening delivery is enabled in settings. |
| 565 |
* |
| 566 |
* @return bool |
| 567 |
*/ |
| 568 |
private function is_evening_enabled(): bool { |
| 569 |
return (bool) $this->settings->is_evening_delivery_enabled(); |
| 570 |
} |
| 571 |
|
| 572 |
/** |
| 573 |
* Whether morning delivery is enabled in settings. |
| 574 |
* |
| 575 |
* @return bool |
| 576 |
*/ |
| 577 |
private function is_morning_enabled(): bool { |
| 578 |
return (bool) $this->settings->is_morning_delivery_enabled(); |
| 579 |
} |
| 580 |
|
| 581 |
/** |
| 582 |
* Clamp the requested look-ahead days to the range the V4 endpoint accepts. |
| 583 |
* |
| 584 |
* @param int $days Requested number of days. |
| 585 |
* |
| 586 |
* @return int |
| 587 |
*/ |
| 588 |
private function clamp_days( int $days ): int { |
| 589 |
if ( $days < 1 ) { |
| 590 |
return 1; |
| 591 |
} |
| 592 |
|
| 593 |
return min( $days, self::V4_MAX_DELIVERY_DAYS ); |
| 594 |
} |
| 595 |
} |
| 596 |
|