*/ 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 */ 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; /** * Memoised self-built V4 timeframe service. * * Kept out of $v4_services for the same reason as $label_v4_memo. Memoising also * keeps one SDK client — and so one response cache — across both checkout halves * within a request. * * @var V4_Timeframe_Service|null */ private $timeframe_v4_memo = null; /** * Memoised self-built V4 pickup-location service. * * Kept out of $v4_services for the same reason as $label_v4_memo. * * @var V4_Pickup_Location_Service|null */ private $pickup_location_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' ) ) { if ( isset( $this->v4_services['timeframe'] ) ) { return $this->v4_services['timeframe']; } $v4 = $this->build_v4_timeframe_service(); if ( null !== $v4 ) { return $v4; } } 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' ) ) { if ( isset( $this->v4_services['pickup_location'] ) ) { return $this->v4_services['pickup_location']; } $v4 = $this->build_v4_pickup_location_service(); if ( null !== $v4 ) { return $v4; } } 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']; } /** * Build the V4 timeframe service, or null when this factory cannot. * * Unlike the label and returns services, the V4 checkout services type-hint the * concrete Settings because they read a dozen shipping settings to build their * requests. A factory handed anything else therefore falls back to Legacy rather * than fatalling on the type. In production both call sites (Frontend\Container * and Order\Base) pass Settings::get_instance(), so the guard never trips there. * * @return V4_Timeframe_Service|null * * @since 6.0.0 */ private function build_v4_timeframe_service(): ?V4_Timeframe_Service { if ( ! $this->settings instanceof Settings ) { return null; } if ( null === $this->timeframe_v4_memo ) { $logger = $this->v4_logger(); $this->timeframe_v4_memo = new V4_Timeframe_Service( new Client_Factory( $this->settings, $logger ), $this->settings, (string) $this->settings->get_api_key_new(), (int) $this->settings->get_number_delivery_days(), $logger ); } return $this->timeframe_v4_memo; } /** * Build the V4 pickup-location service, or null when this factory cannot. * * Same Settings type constraint as build_v4_timeframe_service(). * * @return V4_Pickup_Location_Service|null * * @since 6.0.0 */ private function build_v4_pickup_location_service(): ?V4_Pickup_Location_Service { if ( ! $this->settings instanceof Settings ) { return null; } if ( null === $this->pickup_location_v4_memo ) { $logger = $this->v4_logger(); $this->pickup_location_v4_memo = new V4_Pickup_Location_Service( new Client_Factory( $this->settings, $logger ), $this->settings, (string) $this->settings->get_api_key_new(), (int) $this->settings->get_number_pickup_points(), $logger ); } return $this->pickup_location_v4_memo; } /** * 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(); } }