# woo-postnl/trunk/src/Rest_API/Service_Factory.php

PostNL for WooCommerce, version trunk. 437 lines.

- Page: https://pluginprobe.com/plugins/woo-postnl/trunk/code/src/Rest_API/Service_Factory.php
- Raw: https://pluginprobe.com/plugins/woo-postnl/trunk/raw/src/Rest_API/Service_Factory.php
- Modified: 2026-08-31T09:58:16+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/woo-postnl/trunk/code/src/Rest_API/Service_Factory.php#L10-L20`.

```php
<?php
/**
 * Class Rest_API\Service_Factory file.
 *
 * @package PostNLWooCommerce\Rest_API
 */

declare( strict_types = 1 );

namespace PostNLWooCommerce\Rest_API;

use PostNLWooCommerce\Logger;
use PostNLWooCommerce\Rest_API\Contracts\Barcode_Service_Interface;
use PostNLWooCommerce\Rest_API\Contracts\Label_Service_Interface;
use PostNLWooCommerce\Rest_API\Contracts\Pickup_Location_Service_Interface;
use PostNLWooCommerce\Rest_API\Contracts\Postcode_Check_Service_Interface;
use PostNLWooCommerce\Rest_API\Contracts\Return_Label_Service_Interface;
use PostNLWooCommerce\Rest_API\Contracts\Smart_Returns_Service_Interface;
use PostNLWooCommerce\Rest_API\Contracts\Timeframe_Service_Interface;
use PostNLWooCommerce\Rest_API\Legacy\Barcode_Service as Legacy_Barcode_Service;
use PostNLWooCommerce\Rest_API\Legacy\Checkout_Service as Legacy_Checkout_Service;
use PostNLWooCommerce\Rest_API\Legacy\Label_Service as Legacy_Label_Service;
use PostNLWooCommerce\Rest_API\Legacy\Letterbox_Service as Legacy_Letterbox_Service;
use PostNLWooCommerce\Rest_API\Legacy\Postcode_Check_Service as Legacy_Postcode_Check_Service;
use PostNLWooCommerce\Rest_API\Legacy\Return_Label_Service as Legacy_Return_Label_Service;
use PostNLWooCommerce\Rest_API\Legacy\Smart_Returns_Service as Legacy_Smart_Returns_Service;
use PostNLWooCommerce\Rest_API\SDK\Client_Factory;
use PostNLWooCommerce\Rest_API\SDK\Logger_Adapter;
use PostNLWooCommerce\Rest_API\V4\Label\Service as V4_Label_Service;
use PostNLWooCommerce\Rest_API\V4\Returns\Service as V4_Returns_Service;
use PostNLWooCommerce\Rest_API\V4\Returns\Smart_Returns_Service as V4_Smart_Returns_Service;
use Psr\Log\LoggerInterface;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class Service_Factory
 *
 * Single factory that resolves the correct service implementation per flow.
 * Every method returns the Legacy service unless all three conditions hold:
 *   (a) a validated "New API Key" is present on the settings object,
 *   (b) Router::sdk_enabled_for() returns true for the flow, and
 *   (c) a V4 service has been registered for that flow via inject_v4_service().
 *
 * postcode_check_service() is permanently wired to Legacy because postcode_check
 * is intentionally absent from Router::SUPPORTED_FLOWS.
 *
 * The 'shipment_and_return' SUPPORTED_FLOW has no factory method yet: no service
 * wrapper or interface exists for it on this branch. The S&R method lands with
 * its interface when that flow is migrated.
 *
 * The 'checkout' SUPPORTED_FLOW is never queried directly; the checkout endpoint
 * is split here into the 'timeframe' and 'pickup_location' flows, both backed by
 * the shared Legacy\Checkout_Service.
 *
 * Legacy services are created lazily on first access and memoised so repeated
 * calls within a request are cheap.
 *
 * @since   5.9.9
 * @package PostNLWooCommerce\Rest_API
 */
class Service_Factory {

	/**
	 * Plugin settings instance used to detect a V4 API key.
	 * Null when no settings object is available yet (e.g. early bootstrap).
	 *
	 * @var object|null
	 */
	private $settings;

	/**
	 * V4 service instances keyed by flow name.
	 * Populated via inject_v4_service(); future SDK implementations are registered here.
	 * Also used in unit tests to inject test doubles.
	 *
	 * @var array<string, object>
	 */
	private $v4_services = array();

	/**
	 * Memoised service instances keyed by flow name (or 'checkout' for the shared
	 * Legacy\Checkout_Service used by both timeframe and pickup_location).
	 * May be pre-seeded via set_legacy_service() in unit tests to avoid
	 * instantiating Order\Base-derived classes that require WooCommerce.
	 *
	 * @var array<string, object>
	 */
	private $legacy_memos = array();

	/**
	 * Memoised self-built V4 label service.
	 *
	 * Deliberately kept out of $v4_services so that array keeps its single meaning —
	 * "a V4 service was explicitly injected for this flow" — which barcode_from_label()
	 * depends on.
	 *
	 * @var V4_Label_Service|null
	 */
	private $label_v4_memo = null;

	/**
	 * Memoised self-built V4 returns service.
	 *
	 * Kept out of $v4_services for the same reason as $label_v4_memo: that array
	 * means "a V4 service was explicitly injected for this flow", and giving it a
	 * second meaning is how a later predicate reading it gets a wrong answer.
	 *
	 * @var V4_Returns_Service|null
	 */
	private $return_label_v4_memo = null;

	/**
	 * Memoised self-built V4 smart-returns service.
	 *
	 * Kept out of $v4_services for the same reason as $label_v4_memo: that array
	 * means "a V4 service was explicitly injected for this flow", and giving it a
	 * second meaning is how a later predicate reading it gets a wrong answer.
	 *
	 * @var V4_Smart_Returns_Service|null
	 */
	private $smart_returns_v4_memo = null;

	/**
	 * Service_Factory constructor.
	 *
	 * @param object|null $settings Plugin settings instance, or null when unavailable.
	 */
	public function __construct( $settings = null ) {
		$this->settings = $settings;
	}

	/**
	 * Register a V4 service for a specific flow.
	 *
	 * Called during V4 wiring once SDK service classes exist.
	 * Also used in unit tests to inject lightweight test doubles.
	 *
	 * @param string $flow    Flow identifier (should be in Router::SUPPORTED_FLOWS).
	 * @param object $service V4 service instance implementing the flow's interface.
	 * @return void
	 */
	public function inject_v4_service( string $flow, object $service ): void {
		$this->v4_services[ $flow ] = $service;
	}

	/**
	 * Pre-seed the memoisation store with a ready-built service instance.
	 *
	 * Used exclusively in unit tests to avoid instantiating Legacy\Label_Service,
	 * Legacy\Letterbox_Service, and Legacy\Return_Label_Service, which extend
	 * Order\Base and require WooCommerce constants and Settings::get_instance()
	 * in their constructors.  Not intended for production use.
	 *
	 * @param string $flow    Flow identifier.
	 * @param object $service Service instance implementing the flow's interface.
	 * @return void
	 */
	public function set_legacy_service( string $flow, object $service ): void {
		$this->legacy_memos[ $flow ] = $service;
	}

	/**
	 * Return the barcode service for the current configuration.
	 *
	 * @return Barcode_Service_Interface
	 */
	public function barcode_service(): Barcode_Service_Interface {
		if ( $this->should_use_v4( 'barcode' ) && isset( $this->v4_services['barcode'] ) ) {
			return $this->v4_services['barcode'];
		}
		if ( ! isset( $this->legacy_memos['barcode'] ) ) {
			$this->legacy_memos['barcode'] = new Legacy_Barcode_Service();
		}
		return $this->legacy_memos['barcode'];
	}

	/**
	 * Whether the barcode is issued by the label response instead of a standalone
	 * barcode request.
	 *
	 * Gated on the same condition as label_service(), so the reorder can never select
	 * a Legacy label service that still expects a prefetched barcode.
	 *
	 * @return bool
	 *
	 * @since 6.0.0
	 */
	public function barcode_from_label(): bool {
		return $this->should_use_v4( 'label' ) && isset( $this->v4_services['label'] );
	}

	/**
	 * Return the timeframe (delivery options) service for the current configuration.
	 *
	 * @return Timeframe_Service_Interface
	 */
	public function timeframe_service(): Timeframe_Service_Interface {
		if ( $this->should_use_v4( 'timeframe' ) && isset( $this->v4_services['timeframe'] ) ) {
			return $this->v4_services['timeframe'];
		}
		return $this->legacy_checkout_service();
	}

	/**
	 * Return the pickup-location service for the current configuration.
	 *
	 * @return Pickup_Location_Service_Interface
	 */
	public function pickup_location_service(): Pickup_Location_Service_Interface {
		if ( $this->should_use_v4( 'pickup_location' ) && isset( $this->v4_services['pickup_location'] ) ) {
			return $this->v4_services['pickup_location'];
		}
		return $this->legacy_checkout_service();
	}

	/**
	 * Return the outbound shipping label service for the current configuration.
	 *
	 * @return Label_Service_Interface
	 */
	public function label_service(): Label_Service_Interface {
		if ( $this->should_use_v4( 'label' ) ) {
			// A service injected via inject_v4_service() wins; otherwise build the real V4 service.
			// The per-combination V4_Mapper gate lives inside the service, which falls back to the
			// legacy pipeline for anything outside the happy-path domestic parcel.
			if ( isset( $this->v4_services['label'] ) ) {
				return $this->v4_services['label'];
			}
			// Memoised apart from $v4_services on purpose. That array means "deliberately
			// injected", and barcode_from_label() reads it to decide whether Order\Base may
			// skip the barcode prefetch. Caching a self-built instance there would flip that
			// answer mid-request: in a bulk run the first order prefetches a barcode and
			// builds this service, and every later order on the same Order\Bulk instance
			// would then skip the prefetch and hand Shipping\Item_Info no main_barcode.
			if ( null === $this->label_v4_memo ) {
				$logger              = $this->v4_logger();
				$this->label_v4_memo = new V4_Label_Service(
					new Client_Factory( $this->settings, $logger ),
					(string) $this->settings->get_api_key_new(),
					$logger
				);
			}
			return $this->label_v4_memo;
		}
		if ( ! isset( $this->legacy_memos['label'] ) ) {
			$this->legacy_memos['label'] = new Legacy_Label_Service();
		}
		return $this->legacy_memos['label'];
	}

	/**
	 * Return the letterbox label service for the current configuration.
	 *
	 * @return Label_Service_Interface
	 */
	public function letterbox_service(): Label_Service_Interface {
		if ( $this->should_use_v4( 'letterbox' ) && isset( $this->v4_services['letterbox'] ) ) {
			return $this->v4_services['letterbox'];
		}
		if ( ! isset( $this->legacy_memos['letterbox'] ) ) {
			$this->legacy_memos['letterbox'] = new Legacy_Letterbox_Service();
		}
		return $this->legacy_memos['letterbox'];
	}

	/**
	 * Return the return-label service for the current configuration.
	 *
	 * @return Return_Label_Service_Interface
	 */
	public function return_label_service(): Return_Label_Service_Interface {
		if ( $this->should_use_v4( 'return_label' ) ) {
			// A service injected via inject_v4_service() wins; otherwise build the real V4
			// service, which handles the NL retailPrint return and falls back to the legacy
			// pipeline for the rest.
			if ( isset( $this->v4_services['return_label'] ) ) {
				return $this->v4_services['return_label'];
			}
			// Memoised apart from $v4_services for the same reason as label_service():
			// that array means "deliberately injected", and barcode_from_label() reads it.
			// Caching a self-built instance there would give the array two meanings, and
			// the next predicate written against it would silently get the wrong answer.
			if ( null === $this->return_label_v4_memo ) {
				$logger                     = $this->v4_logger();
				$this->return_label_v4_memo = new V4_Returns_Service(
					new Client_Factory( $this->settings, $logger ),
					(string) $this->settings->get_api_key_new(),
					$logger
				);
			}
			return $this->return_label_v4_memo;
		}
		if ( ! isset( $this->legacy_memos['return_label'] ) ) {
			$this->legacy_memos['return_label'] = new Legacy_Return_Label_Service();
		}
		return $this->legacy_memos['return_label'];
	}

	/**
	 * Return the postcode-check service.
	 *
	 * Always returns Legacy — postcode_check is not in Router::SUPPORTED_FLOWS
	 * and is not planned for V4 routing.
	 *
	 * @return Postcode_Check_Service_Interface
	 */
	public function postcode_check_service(): Postcode_Check_Service_Interface {
		if ( ! isset( $this->legacy_memos['postcode_check'] ) ) {
			$this->legacy_memos['postcode_check'] = new Legacy_Postcode_Check_Service();
		}
		return $this->legacy_memos['postcode_check'];
	}

	/**
	 * Return the smart-returns service for the current configuration.
	 *
	 * @return Smart_Returns_Service_Interface
	 */
	public function smart_returns_service(): Smart_Returns_Service_Interface {
		if ( $this->should_use_v4( 'smart_returns' ) ) {
			// A service injected via inject_v4_service() wins; otherwise build the real V4
			// service, which handles the NL retailPrint Smart Return and falls back to the
			// legacy pipeline for the rest.
			if ( isset( $this->v4_services['smart_returns'] ) ) {
				return $this->v4_services['smart_returns'];
			}
			// Memoised apart from $v4_services for the same reason as label_service():
			// that array means "deliberately injected", and barcode_from_label() reads it.
			// Caching a self-built instance there would give the array two meanings, and
			// the next predicate written against it would silently get the wrong answer.
			if ( null === $this->smart_returns_v4_memo ) {
				$logger                      = $this->v4_logger();
				$this->smart_returns_v4_memo = new V4_Smart_Returns_Service(
					new Client_Factory( $this->settings, $logger ),
					(string) $this->settings->get_api_key_new(),
					$logger
				);
			}
			return $this->smart_returns_v4_memo;
		}
		if ( ! isset( $this->legacy_memos['smart_returns'] ) ) {
			$this->legacy_memos['smart_returns'] = new Legacy_Smart_Returns_Service();
		}
		return $this->legacy_memos['smart_returns'];
	}

	/**
	 * Return the shared Legacy\Checkout_Service instance.
	 *
	 * Both timeframe_service() and pickup_location_service() delegate here because
	 * the PostNL checkout endpoint returns delivery options and pickup locations in
	 * a single response — one service instance covers both flows.
	 *
	 * @return Legacy_Checkout_Service
	 */
	private function legacy_checkout_service(): Legacy_Checkout_Service {
		if ( ! isset( $this->legacy_memos['checkout'] ) ) {
			$this->legacy_memos['checkout'] = new Legacy_Checkout_Service();
		}
		return $this->legacy_memos['checkout'];
	}

	/**
	 * Build the PSR-3 logger the V4 services and the SDK transport report through.
	 *
	 * Equivalent to Main::get_logger() wrapped in a Logger_Adapter — the wiring the
	 * V4 services document — but built from the settings object this factory was
	 * handed rather than reaching back for the Settings singleton, so the factory
	 * has a single source of settings.
	 *
	 * Only called from a branch has_v4_key() already guarded, so $this->settings is
	 * never null here.
	 *
	 * @return LoggerInterface
	 */
	private function v4_logger(): LoggerInterface {
		return new Logger_Adapter( new Logger( (bool) $this->settings->is_logging_enabled() ) );
	}

	/**
	 * Return whether V4 routing should be used for the given flow.
	 *
	 * Short-circuits on the key check so Router (and its filter) is never consulted
	 * when no V4 key is configured.
	 *
	 * Deferred by design: product-coded flows (barcode, label, letterbox,
	 * return_label, smart_returns) must additionally gate on
	 * V4_Mapper::has_v4_equivalent(...). That gate needs a resolved product
	 * combination, which only exists once each flow builds its request, so it is
	 * wired in alongside the V4 services that need it.
	 *
	 * @param string $flow Flow identifier.
	 * @return bool
	 */
	private function should_use_v4( string $flow ): bool {
		return $this->has_v4_key() && Router::sdk_enabled_for( $flow );
	}

	/**
	 * Return whether a validated V4-capable API key is available on the settings object.
	 *
	 * The V4-capable key is the separate "New API Key" field, not the original key.
	 * It only becomes usable once a save-time validation call has confirmed it, so an
	 * entered-but-unvalidated key must never route traffic to V4.
	 *
	 * get_effective_api_key() is deliberately not used here: it answers "which key do
	 * we send" and falls back to the original key, so it is never empty and would
	 * report a V4 key on every site.
	 *
	 * Returns false when: no settings object was injected; the settings object does not
	 * yet expose the new-key accessors (that field ships in its own in-progress PR); the
	 * key is empty or whitespace-only; or the entered key has not passed validation.
	 *
	 * @return bool
	 */
	private function has_v4_key(): bool {
		if ( null === $this->settings ) {
			return false;
		}
		if ( ! method_exists( $this->settings, 'get_api_key_new' )
			|| ! method_exists( $this->settings, 'is_api_key_new_validated' ) ) {
			return false;
		}

		$key = $this->settings->get_api_key_new();

		if ( ! is_string( $key ) || '' === trim( $key ) ) {
			return false;
		}

		return true === $this->settings->is_api_key_new_validated();
	}
}

```
