Raw SaaS sub-payload; normalized by Sales_Channel_Resource. */ public readonly array $sales_channel, public readonly string $channel_name, public readonly string $sales_channel_permalink, public readonly ?string $sales_channel_extra_order_data, public readonly Order_Address $shipping_address, public readonly Order_Address $billing_address, /** @var array */ public readonly array $items, public readonly bool $archived, public readonly bool $synced_platform, public readonly bool $synced_tracking, public readonly bool $has_tracking, public readonly bool $import_error, public readonly mixed $error_status, public readonly bool $anonymized, public readonly bool $fulfillment_by_marketplace, public readonly bool $is_prime, public readonly bool $is_business, public readonly ?bool $review_request_sent, public readonly ?string $cancelled_at, public readonly ?string $cancel_reason, public readonly int|float|string|bool|null $location_id, public readonly ?string $metadata, private readonly bool $has_item_list, ) {} /** * Builds an order from one SaaS order payload. * * Returns null when the payload carries no order ID: without one the order * cannot be displayed, imported or reported back, so there is nothing a * consumer could usefully do with it. */ public static function from_saas( mixed $value ): ?self { if ( ! is_array( $value ) || empty( $value['id'] ) ) { return null; } $sales_channel = self::array_value( $value['sales_channel'] ?? null ); $channel_type = self::array_value( $sales_channel['channel_type'] ?? null ); return new self( id: (int) self::scalar_value( $value, 'id' ), order_number: self::string_value( $value, 'order_number' ), platform_order_number: self::string_value( $value, 'platform_order_number' ), shop_name: self::string_value( $value, 'shop_name' ), date: self::string_value( $value, 'date' ), order_placed_date: self::string_value( $value, 'order_placed_date' ), status: self::string_value( $value, 'status' ), currency: self::string_value( $value, 'currency' ), charge_total: self::float_value( $value, 'charge_total' ), charge_subtotal: self::float_value( $value, 'charge_subtotal' ), charge_tax: self::float_value( $value, 'charge_tax' ), shipping_costs: self::float_or_null( $value, 'shipping_costs' ), shipping_tax: self::float_or_null( $value, 'shipping_tax' ), shipment_type: self::string_value( $value, 'shipment_type' ), commission: self::float_or_null( $value, 'commission' ), latest_delivery_date: self::string_or_null( $value, 'latest_delivery_date' ), discount: self::float_or_null( $value, 'discount' ), sales_channel: $sales_channel, channel_name: self::string_value( $channel_type, 'name' ), sales_channel_permalink: self::string_value( $value, 'sales_channel_permalink' ), sales_channel_extra_order_data: self::string_or_json( $value['sales_channel_extra_order_data'] ?? null ), shipping_address: Order_Address::from_saas( $value['shipping_address'] ?? null ), billing_address: Order_Address::from_saas( $value['billing_address'] ?? null ), items: Order_Item::list_from_saas( $value['items'] ?? [] ), archived: self::bool_value( $value, 'archived' ), synced_platform: self::bool_value( $value, 'synced_platform' ), synced_tracking: self::bool_value( $value, 'synced_tracking' ), has_tracking: self::bool_value( $value, 'has_tracking' ), import_error: self::bool_value( $value, 'import_error' ), error_status: $value['error_status'] ?? null, anonymized: self::bool_value( $value, 'anonymized' ), fulfillment_by_marketplace: self::bool_value( $value, 'fulfillment_by_marketplace' ), is_prime: self::bool_value( $value, 'is_prime' ), is_business: self::bool_value( $value, 'is_business' ), review_request_sent: isset( $value['review_request_sent'] ) ? (bool) $value['review_request_sent'] : null, cancelled_at: self::string_or_null( $value, 'cancelled_at' ), cancel_reason: self::string_or_null( $value, 'cancel_reason' ), location_id: self::scalar_value( $value, 'location_id' ), metadata: self::string_or_json( $value['metafields'] ?? $value['metadata'] ?? null ), has_item_list: is_array( $value['items'] ?? null ), ); } /** * Builds an order from a SaaS response, with or without a `data` envelope. * * Single-order endpoints wrap the order; some return it bare. Both callers * unwrapped this the same way before, so the unwrapping lives here now. */ public static function from_saas_response( mixed $response ): ?self { $body = self::array_value( $response ); return self::from_saas( is_array( $body['data'] ?? null ) ? $body['data'] : $body ); } /** * Builds the orders of a paginated SaaS list response. * * @return array */ public static function list_from_saas_response( mixed $response ): array { $body = self::array_value( $response ); return array_values( array_filter( array_map( [ self::class, 'from_saas' ], self::array_value( $body['data'] ?? null ) ) ) ); } /** * Whether this order still has to reach WooCommerce. * * The single guard the pending scan and the import both apply. An archived * order was dismissed by hand; `synced_platform` and a platform order * number each mean WooCommerce already has it, and importing again would * create a duplicate order for the customer. */ public function is_pending_import(): bool { return ! $this->archived && ! $this->synced_platform && absint( $this->platform_order_number ) < 1; } /** * The items the UI can key by ID. See Order_Item::is_identified(). * * @return array */ public function identified_items(): array { return array_values( array_filter( $this->items, static fn ( Order_Item $item ): bool => $item->is_identified() ) ); } /** * Whether every item claims a link to a WooCommerce product. * * Null when the payload carried no item list at all: linkage is then unknown * rather than complete, and order list endpoints do omit the items. */ public function products_linked(): ?bool { if ( ! $this->has_item_list ) { return null; } foreach ( $this->identified_items() as $item ) { if ( ! $item->claims_platform_link() ) { return false; } } return true; } /** * The order number to quote back, preferring the one the customer would * recognise from their own store. * * Never empty: a marketplace that sends a blank order number should not * leave a WooCommerce note reading "Order #". */ public function reference(): string { foreach ( [ $this->platform_order_number, $this->order_number ] as $candidate ) { if ( '' !== trim( $candidate ) ) { return $candidate; } } return (string) $this->id; } /** * How this order is named in logs and in admin progress messages. * * Always the marketplace's own order number, so one order reads the same * whether the hourly scan or a human queued it, and falls back to the SaaS * ID so a log line is never anonymous. */ public function label(): string { $order_number = sanitize_text_field( $this->order_number ); return '' === $order_number ? '#' . $this->id : $order_number; } /** * A scalar as a string, an array as JSON, anything else as null. * * Marketplace metadata and extra order data are free-form: the SaaS sends a * string for one marketplace and a structured object for the next, and the * UI renders whichever it gets as text. */ private static function string_or_json( mixed $value ): ?string { if ( null === $value || '' === $value ) { return null; } if ( is_scalar( $value ) ) { return (string) $value; } if ( is_array( $value ) ) { return wp_json_encode( $value ) ?: null; } return null; } }