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 / Rest_API / V4 / Timeframe / Service.php

Service.php in PostNL for WooCommerce trunk, at src/Rest_API/V4/Timeframe/Service.php

596 lines 20.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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