client_factory = $client_factory; $this->v4_key = $v4_key; $this->logger = $logger; } /** * No WP hooks are registered by this service class. * * Required by Order\Base but intentionally a no-op here. */ public function init_hooks() { // Intentionally empty — this service does not register WordPress hooks. } /** * Create a shipping label and return the normalized label record. * * Routes the happy-path domestic parcel to the V4 SDK; anything else runs * the untouched legacy pipeline. * * @param array $post_data Context needed to build and send the label request. * * @return array Normalized label record keyed by label-type string. * * @throws \Exception If the SDK request fails (converted to the legacy error shape). */ public function create( array $post_data ): array { $item_info = new Shipping\Item_Info( $post_data ); $signals = $this->gather_signals( $item_info, $post_data ); if ( ! Eligibility::is_eligible( $signals ) ) { return $this->create_label_pipeline( $post_data ); } $fields = $this->extract_fields( $item_info, $signals['mapped'], $post_data ); $request = Request_Builder::build( $fields ); $response = $this->confirm_label( $request, $fields ); $barcodes = ! empty( $fields['barcodes'] ) ? $fields['barcodes'] : array( (string) $fields['barcode'] ); return $this->store_labels( $response, $post_data['order'], $barcodes ); } /** * Send the labelconfirm request, converting and logging any failure. * * @param ShipmentDeliveryRequest $request Built labelconfirm request. * @param array $fields Flattened field set the request was built from. * * @return LabelConfirmResponseInterface * * @throws \Exception Converted SDK error when the request fails. */ protected function confirm_label( ShipmentDeliveryRequest $request, array $fields ): LabelConfirmResponseInterface { try { return $this->build_client()->shipmentDelivery()->labelConfirm( $request ); } 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(). // // The shipment reference is the merchant's own order number: it is what // makes the entry traceable back to a shipment, and it is store-internal // rather than customer-identifying, so it is kept where the address parts // are not. $this->logger->error( sprintf( 'V4 label creation failed for order "%1$s" to "%2$s": %3$s (cause: %4$s: %5$s)', (string) ( $fields['reference'] ?? '' ), $this->describe_destination( $fields ), $error->getMessage(), get_class( $exception ), $exception->getMessage() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception_Converter returns an already-escaped, translated message. throw $error; } } /** * Short, log-safe description of the destination a failed label was for. * * Mirrors V4\Timeframe\Service::describe_destination(): legacy * Rest_API\Base::send_request() writes the entire request body — recipient name, * street, house number, city, email and phone included — to the same WooCommerce * log, whereas this keeps only the country and the postcode's leading area * digits. 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 $fields Flattened field set. * * @return string e.g. 'NL 1234'; empty when the payload carried no address. */ private function describe_destination( array $fields ): string { $country = (string) ( $fields['receiver']['country'] ?? '' ); $postcode = str_replace( ' ', '', (string) ( $fields['receiver']['postcode'] ?? '' ) ); return trim( $country . ' ' . substr( $postcode, 0, 4 ) ); } /** * Collect the eligibility signals for an order from the legacy parser. * * The selected backend features are read from the raw post data (string * 'yes' values), not the parsed booleans on Item_Info, because * Utils::get_selected_label_features() matches on 'yes'. Passing the real * options into the mapper is what lets a service-bearing combination that * keeps product 3085 (e.g. insured) resolve to a services row and be * rejected below, rather than silently masquerading as the base parcel. * * @param Shipping\Item_Info $item_info Parsed legacy item info. * @param array $post_data Original label post data. * @return array Signal set consumed by is_eligible(). */ private function gather_signals( Shipping\Item_Info $item_info, array $post_data ): array { $backend_raw = $post_data['saved_data']['backend'] ?? array(); $origin = (string) ( $item_info->shipper['country'] ?? '' ); $destination = Utils::get_shipping_zone( (string) ( $item_info->receiver['country'] ?? '' ), (string) ( $item_info->receiver['state'] ?? '' ) ); $has_return = ! empty( $item_info->shipment['return_barcode'] ) || ! empty( $item_info->shipment['shipping_return_barcode'] ) || ! empty( $item_info->shipment['return_options'] ) || 'yes' === ( $backend_raw['create_return_label'] ?? '' ) || 'yes' === ( $backend_raw['create_shipment_return_label'] ?? '' ); return array( 'num_labels' => (int) ( $item_info->backend_data['num_labels'] ?? 1 ), 'is_delivery_day' => $item_info->is_delivery_day(), 'is_pickup' => $item_info->is_pickup_points(), 'has_return' => $has_return, 'delivery_type' => (string) ( $item_info->backend_data['delivery_type'] ?? 'Standard' ), 'origin' => $origin, 'destination' => $destination, 'mapped' => Eligibility::resolve_mapped( $origin, $destination, $item_info->is_pickup_points(), $backend_raw, (string) $item_info->get_product_code() ), ); } /** * Flatten the parsed item info into the field array Request_Builder consumes. * * @param Shipping\Item_Info $item_info Parsed legacy item info. * @param array $mapped V4_Mapper::map() result. * @param array $post_data Original label post data. * @return array */ private function extract_fields( Shipping\Item_Info $item_info, array $mapped, array $post_data ): array { $num_labels = (int) ( $item_info->backend_data['num_labels'] ?? 1 ); // Legacy caps the collo count it puts on the wire through the num_labels // sanitizer (1-10), which the V1 request loop iterates; the barcode prefetch // in Order\Base::maybe_create_multi_barcodes() reads the raw backend value and // applies no cap, and the meta-box field has a min but no max. Slicing to the // parsed count keeps V4 shipping the same colli V1 would -- surplus barcodes // go unused, exactly as on V1. $barcodes = array_values( array_filter( (array) ( $post_data['barcodes'] ?? array() ), 'is_scalar' ) ); $barcodes = array_slice( $barcodes, 0, $num_labels ); return array( 'sender' => array( 'company' => $item_info->shipper['company'] ?? '', 'street' => $item_info->shipper['address_1'] ?? '', 'house_number' => $item_info->shipper['address_2'] ?? '', 'house_number_ext' => '', 'postcode' => $item_info->shipper['postcode'] ?? '', 'city' => $item_info->shipper['city'] ?? '', 'country' => $item_info->shipper['country'] ?? '', ), 'receiver' => array( 'company' => $item_info->receiver['company'] ?? '', 'first_name' => $item_info->receiver['first_name'] ?? '', 'last_name' => $item_info->receiver['last_name'] ?? '', 'street' => $item_info->receiver['address_1'] ?? '', 'house_number' => $item_info->receiver['house_number'] ?? '', 'house_number_ext' => $item_info->receiver['address_2'] ?? '', 'postcode' => $item_info->receiver['postcode'] ?? '', 'city' => $item_info->receiver['city'] ?? '', 'country' => $item_info->receiver['country'] ?? '', 'email' => $item_info->shipment['email'] ?? '', 'phone' => $item_info->shipment['phone'] ?? '', ), 'shipment_type' => $mapped['shipmentType'] ?? 'parcel', 'weight_gr' => (int) ( $item_info->shipment['total_weight'] ?? 0 ), 'reference' => (string) ( $item_info->shipment['order_number'] ?? '' ), 'barcode' => (string) ( $post_data['main_barcode'] ?? '' ), 'barcodes' => $barcodes, // The collo count is carried even when barcodes are pre-issued: it is the // only record of it when the label call issues the barcodes instead, and // Request_Builder ignores it whenever a barcode is supplied. 'num_labels' => $num_labels, 'services' => Eligibility::resolve_services( $mapped['services'] ?? array(), (float) ( $item_info->shipment['subtotal'] ?? 0 ) ), 'international' => $this->extract_international( $item_info, $mapped ), 'label' => Request_Builder::printer_type_to_label_settings( (string) ( $item_info->shipment['printer_type'] ?? '' ) ), ); } /** * Flatten the international shipment data (bundle + customs) for an EU/ROW order. * * Returns an empty array for a domestic shipment — the mapper only carries an * internationalShipmentData hint (the service bundle) for EU/ROW parcels, so * its presence is what distinguishes the two. * * @param Shipping\Item_Info $item_info Parsed legacy item info. * @param array $mapped V4_Mapper::map() result. * @return array */ private function extract_international( Shipping\Item_Info $item_info, array $mapped ): array { $international = $mapped['internationalShipmentData'] ?? array(); if ( empty( $international ) ) { return array(); } $fields = array( 'bundle' => (string) ( $international['bundle'] ?? '' ), 'customs' => $this->extract_customs( $item_info ), ); $this->warn_unmappable_international( $fields, (string) ( $item_info->shipment['order_number'] ?? '' ) ); return $fields; } /** * Report the international values Request_Builder will silently drop. * * The log lives here rather than in the builder because the builder is a pure * static translator — a flat array in, a DTO out, no collaborators — so giving it * a logger would mean threading one through every call site just to translate a * field. This is the last point where the logger, the raw values and the order * reference all exist together. * * Each check mirrors the builder's own normalization exactly: Bundle::tryFrom on * the raw string (the builder folds no case there), Currency::tryFrom and * Country::tryFrom on the upper-cased value. Anything looser would warn about * values that reach PostNL perfectly well — a lowercase 'eur' — and train * merchants to ignore the log. Empty values are omitted by the builder by design * and are not reported. * * Without this, an enum miss is invisible: the SDK Currency enum carries only * thirteen currencies, so a store selling in HUF sends a customs declaration with * no currency, and a mistyped mapper bundle ships an uninsured parcel. The order * number is the merchant's own reference, and currency/country codes carry no * personal data, so the whole line is safe to write unconditionally. * * @param array $fields Flattened international field set. * @param string $order_reference Merchant order number the shipment belongs to. * @return void */ private function warn_unmappable_international( array $fields, string $order_reference ): void { $bundle = (string) ( $fields['bundle'] ?? '' ); if ( '' !== $bundle && null === Bundle::tryFrom( $bundle ) ) { $this->logger->warning( sprintf( 'V4 label for order "%1$s": unknown international service bundle "%2$s"; the shipment is sent without a bundle.', $order_reference, $bundle ) ); } $currency = (string) ( $fields['customs']['currency'] ?? '' ); if ( '' !== $currency && null === Currency::tryFrom( strtoupper( $currency ) ) ) { $this->logger->warning( sprintf( 'V4 label for order "%1$s": unsupported customs currency "%2$s"; the customs declaration is sent without a currency.', $order_reference, $currency ) ); } foreach ( (array) ( $fields['customs']['content'] ?? array() ) as $item ) { $origin = (string) ( $item['country_of_origin'] ?? '' ); if ( '' !== $origin && null === Country::tryFrom( strtoupper( $origin ) ) ) { $this->logger->warning( sprintf( 'V4 label for order "%1$s": unknown customs country of origin "%2$s"; that item is declared without a country of origin.', $order_reference, $origin ) ); } } } /** * Build the customs declaration fields from the parsed order line items. * * Mirrors the legacy Customs block: transactionCode 11 with an invoice * associatedDocument, the order currency, a trusted-shipper * senderIdentification from the merchant code (empty for EU destinations), * and one content entry per non-virtual line item. * * @param Shipping\Item_Info $item_info Parsed legacy item info. * @return array */ private function extract_customs( Shipping\Item_Info $item_info ): array { $content = array(); foreach ( (array) $item_info->contents as $item ) { $content[] = array( 'description' => (string) ( $item['description'] ?? '' ), 'quantity' => (int) ( $item['qty'] ?? 1 ), 'weight' => (int) ( $item['weight'] ?? 0 ), 'value' => (float) ( $item['value'] ?? 0 ), 'country_of_origin' => (string) ( $item['origin'] ?? '' ), 'hs_code' => (string) ( $item['hs_code'] ?? '' ), ); } return array( 'currency' => (string) ( $item_info->shipment['currency'] ?? '' ), 'transaction_code' => '11', 'associated_document' => array( 'type' => 'invoice', // Mirrors the legacy Customs InvoiceNr, which uses the order id (not the display order number). 'number' => (string) ( $item_info->shipment['order_id'] ?? '' ), ), 'sender_identification' => (string) ( $item_info->shipment['merchant_code'] ?? '' ), 'content' => $content, ); } /** * Build the configured SDK client for the current environment. * * @return PostnlClientInterface */ private function build_client(): PostnlClientInterface { return $this->client_factory->build( $this->v4_key, (bool) $this->settings->is_sandbox() ); } /** * Write the labelconfirm response labels to disk and normalize them. * * Handles single- and multi-collo shipments: each returned shipment item is * one collo carrying its own barcode and label document(s). Every collo's * labels are written and then merged through Order\Base::maybe_merge_labels() * — which, for more than one collo, combines them into a single sheet keyed by * the parent barcode, matching the legacy path — then handed to * finalize_label_records() to restore the V4-only keys the merge drops. The * parent barcode is the pre-issued one when the caller had it, and otherwise * the one the response issued for the first collo. * * @param LabelConfirmResponseInterface $response labelconfirm response. * @param \WC_Order $order WooCommerce order. * @param array $fallbacks Pre-issued barcodes per collo; [0] is the parent/main barcode. * @return array * @throws \Exception When the response carries no shipment or no label content. */ private function store_labels( LabelConfirmResponseInterface $response, $order, array $fallbacks ): array { $items = Response_Mapper::all_shipment_items( $response ); if ( empty( $items ) ) { throw new \Exception( esc_html__( 'Cannot create the label. No shipment was returned by PostNL.', 'postnl-for-woocommerce' ) ); } $this->warn_collo_count_mismatch( $fallbacks, count( $items ), $order ); $parent_barcode = (string) ( $fallbacks[0] ?? '' ); $records = array(); $partner_references = array(); foreach ( $items as $index => $item ) { $fallback = (string) ( $fallbacks[ $index ] ?? $parent_barcode ); $item_barcode = Response_Mapper::get_barcode( $item, $fallback ); $partner_barcode = Response_Mapper::get_partner_barcode( $item ); $partner_id = Response_Mapper::get_partner_id( $item ); // One entry per collo that has partner data, so an international // multi-collo order keeps every collo's partner tracking number, not // just the parent's flat keys below. if ( '' !== $partner_barcode || '' !== $partner_id ) { $partner_references[] = array( 'barcode' => $item_barcode, 'partner_barcode' => $partner_barcode, 'partner_id' => $partner_id, ); } $records = array_merge( $records, $this->item_label_records( $item, $order, $item_barcode ) ); } if ( empty( $records ) ) { throw new \Exception( esc_html__( 'Cannot create the label. Label content is missing', 'postnl-for-woocommerce' ) ); } // Legacy pre-issues the parent barcode and it wins untouched; the harvest path // supplies an empty one, and maybe_merge_labels() stamps whatever it is handed // onto the record it rebuilds, so fall back to the barcode the response issued. // Keying the merge on '' leaves the merged record barcode-less, which // Order\Base::harvest_barcodes_or_fail() answers by deleting the labels just // written and aborting the save. $parent_barcode = self::resolve_parent_barcode( $records, $parent_barcode ); // The merge helper rebuilds a fresh record for a multi-collo shipment, so // re-attach the international partner refs (all empty for a domestic // shipment) — the single-collo record already carries the flat keys. // // The flat partner_barcode/partner_id keys hold the parent collo's refs, // matching the single barcode the merged sheet is keyed on. Every collo's // own refs are kept under partner_references, collected above — the merge // collapses the per-collo records into one holding just // type/barcode/created_at/filepath/merged_files, so anything not put back // here never reaches order meta. No code reads partner_references yet; it // is stored so a later per-collo partner Track & Trace feature has the data. return $this->finalize_label_records( $this->maybe_merge_labels( $records, $order, $parent_barcode, 'label' ), Response_Mapper::get_partner_barcode( $items[0] ), Response_Mapper::get_partner_id( $items[0] ), $partner_references ); } /** * Report a labelconfirm response that answered with a different number of colli * than the caller pre-issued barcodes for. * * The mismatch is otherwise entirely silent, and it is silent on both sides of * the boundary: this method stores one label per returned item, while * Order\Base::save_meta_value() persists every prefetched barcode whatever the * response said. A short response therefore leaves the merchant reading three * tracking numbers off an order whose label sheet holds two parcels, with the * third barcode never confirmed with PostNL; a long one writes and merges a label * for a collo no persisted barcode covers. Neither raises an error anywhere. * * Storing what came back is still right — a partial sheet beats none, and the * merchant can reprint — so this only reports; it does not abort. * * Only non-empty barcodes count. create() supplies array( '' ) on the harvest * path, where nothing was pre-issued and the label call issues one barcode per * collo, so a three-item response against that single placeholder is correct and * must not be reported. Comparing the raw list length would warn on every * multi-collo harvest and train merchants to ignore the log. * * The order number is the merchant's own reference and the counts carry no * personal data, so the whole line is safe to write unconditionally. * * @param array $fallbacks Pre-issued barcodes per collo, as create() built them. * @param int $item_count Number of shipment items the response returned. * @param \WC_Order $order WooCommerce order the label belongs to. * @return void */ private function warn_collo_count_mismatch( array $fallbacks, int $item_count, $order ): void { $expected = count( array_filter( $fallbacks, static function ( $barcode ): bool { return is_string( $barcode ) && '' !== $barcode; } ) ); if ( 0 === $expected || $expected === $item_count ) { return; } $this->logger->warning( sprintf( 'V4 label for order "%1$s": %2$d collo barcodes were pre-issued but the labelconfirm response returned %3$d shipment item(s); one label is stored per returned item, so the persisted barcodes and the label sheet do not line up.', (string) $order->get_order_number(), $expected, $item_count ) ); } /** * Write one collo's label document(s) to disk and return their meta records. * * @param \Postnl\Sdk\ResponseData\V4\ShipmentShippingItem $item Shipment item (one collo). * @param \WC_Order $order WooCommerce order. * @param string $item_barcode Barcode for this collo. * @return array */ private function item_label_records( $item, $order, string $item_barcode ): array { $partner_barcode = Response_Mapper::get_partner_barcode( $item ); $partner_id = Response_Mapper::get_partner_id( $item ); $records = array(); foreach ( Response_Mapper::get_labels( $item ) as $label ) { $content = Response_Mapper::decode_content( $label ); if ( '' === $content ) { continue; } // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Third-party SDK DTO properties. $output_type = null !== $label->outputType ? $label->outputType->value : 'pdf'; $label_type = ( null !== $label->labelType && '' !== $label->labelType ) ? sanitize_title( $label->labelType ) : 'label'; // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase $filename = Utils::generate_label_name( $order->get_id(), $label_type, $item_barcode, 'A6', $output_type ); $filepath = trailingslashit( POSTNL_UPLOADS_DIR ) . $filename; $this->write_label_file( $filepath, $content ); // Only record a label that actually exists on disk, so a failed write // never persists a meta entry pointing at a missing file. if ( ! is_file( $filepath ) ) { continue; } $records[] = Response_Mapper::to_label_record( $label_type, $item_barcode, $filepath, $partner_barcode, $partner_id ); } return $records; } /** * Restore the V4-only keys the merge drops from every label record. * * Order\Base::maybe_merge_labels() returns the mapper's record untouched only * when there is exactly one label and the format is A6; on every other path it * discards the record and builds a fresh one carrying just type, barcode, * created_at, filepath and merged_files. A ROW response always takes that path, * since it returns four documents, and a multi-collo shipment always does too, * since every collo contributes at least one label — so the api_version tag and * the partner references captured off the labelconfirm response have to be put * back here or they never reach the order meta. * * Empty partner references add no keys at all, matching * Response_Mapper::to_label_record(): a domestic record carries neither the * flat keys nor a partner_references list. * * @param array $labels Merged label records, keyed by label type. * @param string $partner_barcode Parent collo's partner barcode, or an empty string. * @param string $partner_id Parent collo's partner id, or an empty string. * @param array $partner_references Per-collo partner data: barcode, partner_barcode, partner_id per entry. * @return array */ private function finalize_label_records( array $labels, string $partner_barcode, string $partner_id, array $partner_references = array() ): array { foreach ( array_keys( $labels ) as $key ) { $labels[ $key ]['api_version'] = 'v4'; if ( '' !== $partner_barcode ) { $labels[ $key ]['partner_barcode'] = $partner_barcode; } if ( '' !== $partner_id ) { $labels[ $key ]['partner_id'] = $partner_id; } if ( array() !== $partner_references ) { $labels[ $key ]['partner_references'] = $partner_references; } } return $labels; } /** * Write a decoded label document to disk, creating the uploads dir as needed. * * A pre-existing file is left untouched. Failure is intentionally silent — * store_labels() verifies the file exists afterwards and skips the record if * it does not. * * @param string $filepath Absolute destination path. * @param string $content Raw (decoded) label bytes. * @return void */ private function write_label_file( string $filepath, string $content ): void { if ( is_file( $filepath ) ) { return; } if ( ! wp_mkdir_p( POSTNL_UPLOADS_DIR ) ) { return; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Binary label bytes; mirrors Order\Base::put_label_content(). file_put_contents( $filepath, $content ); } }