<?php
/**
* ScanForm Service class.
*
* @package Automattic\WCShipping\ScanForm
*/
namespace Automattic\WCShipping\ScanForm;
use Automattic\WooCommerce\Utilities\OrderUtil;
use DateTime;
use DateTimeZone;
use WP_Error;
use WP_REST_Request;
/**
* ScanForm Service class.
* Handle logic and database operations.
*/
class ScanFormService {
/**
* Transient key for caching ScanForm history.
*/
const SCANFORM_HISTORY_TRANSIENT_KEY = 'wcshipping_scanform_history';
/**
* Get paginated ScanForms with their orders.
*
* @param int $per_page Number of ScanForms per page.
* @param int $offset Offset for pagination.
* @return array Array with 'scan_forms' and 'total' keys.
*/
public function get_paginated_scan_forms( int $per_page, int $offset ): array {
$scan_forms = $this->get_all_scan_forms();
return array(
'scan_forms' => array_slice( $scan_forms, $offset, $per_page ),
'total' => count( $scan_forms ),
);
}
/**
* Get all ScanForms from cache or database.
*
* Returns ScanForms with origin_address and label_count.
* Results are cached in a transient for performance.
*
* @return array List of ScanForm arrays.
*/
private function get_all_scan_forms(): array {
$cached = get_transient( self::SCANFORM_HISTORY_TRANSIENT_KEY );
if ( false !== $cached && is_array( $cached ) ) {
return $cached;
}
$scan_forms = $this->build_scan_forms_list();
set_transient( self::SCANFORM_HISTORY_TRANSIENT_KEY, $scan_forms, WEEK_IN_SECONDS );
return $scan_forms;
}
/**
* Clear the ScanForm history cache.
*
* @return void
*/
public function clear_history_cache(): void {
delete_transient( self::SCANFORM_HISTORY_TRANSIENT_KEY );
}
/**
* Build the complete ScanForms list from database.
*
* @return array List of ScanForm arrays sorted by creation date.
*/
private function build_scan_forms_list(): array {
$raw_data = $this->fetch_scan_forms_from_database();
if ( empty( $raw_data['scan_forms'] ) ) {
return array();
}
return $this->add_origin_addresses( $raw_data['scan_forms'], $raw_data['order_ids_map'] );
}
/**
* Fetch and deduplicate ScanForms from database.
*
* @return array Array with 'scan_forms' and 'order_ids_map' keys.
*/
private function fetch_scan_forms_from_database(): array {
global $wpdb;
$meta_table = OrderUtil::get_table_for_order_meta();
$meta_join_column = OrderUtil::custom_orders_table_usage_is_enabled() ? 'order_id' : 'post_id';
$results = $wpdb->get_results(
$wpdb->prepare(
'SELECT %i as order_id, meta_value
FROM %i
WHERE meta_key = %s
ORDER BY %i DESC',
$meta_join_column,
$meta_table,
'_wcshipping_scan_forms',
$meta_join_column
)
);
$scan_forms = array();
$order_ids_map = array();
foreach ( $results as $row ) {
$order_id = (int) $row->order_id;
$order_scanforms = maybe_unserialize( $row->meta_value );
if ( empty( $order_scanforms ) || ! is_array( $order_scanforms ) ) {
continue;
}
foreach ( $order_scanforms as $scan_form ) {
if ( ! is_array( $scan_form ) || empty( $scan_form['scan_form_id'] ) ) {
continue;
}
$scan_form_id = $scan_form['scan_form_id'];
// Deduplicate by scan_form_id.
if ( ! isset( $scan_forms[ $scan_form_id ] ) ) {
$scan_forms[ $scan_form_id ] = array(
'scan_form_id' => $scan_form_id,
'pdf_url' => $scan_form['pdf_url'] ?? '',
'created' => $scan_form['created'] ?? '',
'label_ids' => array(),
);
$order_ids_map[ $scan_form_id ] = array();
}
// Merge label IDs from all orders containing this ScanForm.
if ( ! empty( $scan_form['label_ids'] ) && is_array( $scan_form['label_ids'] ) ) {
$scan_forms[ $scan_form_id ]['label_ids'] = array_unique(
array_merge(
$scan_forms[ $scan_form_id ]['label_ids'],
$scan_form['label_ids']
)
);
}
// Track order IDs for this ScanForm.
if ( ! in_array( $order_id, $order_ids_map[ $scan_form_id ], true ) ) {
$order_ids_map[ $scan_form_id ][] = $order_id;
}
}
}
return array(
'scan_forms' => $scan_forms,
'order_ids_map' => $order_ids_map,
);
}
/**
* Add origin addresses and label counts to ScanForms.
*
* @param array $scan_forms Deduplicated ScanForms keyed by ID.
* @param array $order_ids_map Map of scan_form_id to order IDs.
* @return array List of ScanForms sorted by creation date.
*/
private function add_origin_addresses( array $scan_forms, array $order_ids_map ): array {
// Bulk fetch meta data for all orders.
$all_order_ids = array_unique( array_merge( ...array_values( $order_ids_map ) ) );
$scan_forms_meta = $this->get_order_meta_bulk( $all_order_ids, '_wcshipping_scan_forms' );
$labels_meta = $this->get_order_meta_bulk( $all_order_ids, 'wcshipping_labels' );
$selected_origins = $this->get_order_meta_bulk( $all_order_ids, '_wcshipping_selected_origin' );
// Add origin_address and label_count to each ScanForm.
foreach ( $scan_forms as $scan_form_id => &$scan_form ) {
$order_ids = $order_ids_map[ $scan_form_id ] ?? array();
$scan_form['origin_address'] = $this->get_scan_form_origin_address_from_meta(
$scan_form_id,
$order_ids,
$scan_forms_meta,
$labels_meta,
$selected_origins
);
$scan_form['label_count'] = count( $scan_form['label_ids'] );
}
unset( $scan_form );
// Sort by created date (descending).
$result = array_values( $scan_forms );
usort(
$result,
function ( $a, $b ) {
return strtotime( $b['created'] ?? '' ) <=> strtotime( $a['created'] ?? '' );
}
);
return $result;
}
/**
* Get origin address for a ScanForm using pre-fetched meta data.
*
* @param string $scan_form_id The ScanForm ID.
* @param array $order_ids The order IDs containing this ScanForm.
* @param array $scan_forms_meta Pre-fetched _wcshipping_scan_forms meta by order_id.
* @param array $labels_meta Pre-fetched wcshipping_labels meta by order_id.
* @param array $selected_origins Pre-fetched _wcshipping_selected_origin meta by order_id.
* @return array|null Origin address data or null if not found.
*/
private function get_scan_form_origin_address_from_meta( string $scan_form_id, array $order_ids, array $scan_forms_meta, array $labels_meta, array $selected_origins ): ?array {
foreach ( $order_ids as $order_id ) {
// Get the ScanForm data for this order.
$scan_forms = $scan_forms_meta[ $order_id ] ?? null;
if ( empty( $scan_forms ) || ! is_array( $scan_forms ) ) {
continue;
}
// Find the ScanForm with matching ID.
$scan_form_data = null;
foreach ( $scan_forms as $scan_form ) {
if ( ( $scan_form['scan_form_id'] ?? '' ) === $scan_form_id ) {
$scan_form_data = $scan_form;
break;
}
}
if ( empty( $scan_form_data['label_ids'] ) ) {
continue;
}
// Get the first label ID from the ScanForm.
$scan_form_label_id = $scan_form_data['label_ids'][0];
$labels = $labels_meta[ $order_id ] ?? null;
if ( empty( $labels ) ) {
continue;
}
// Find the shipment ID for this label.
$shipment_id = null;
foreach ( $labels as $label ) {
if ( $label['label_id'] === $scan_form_label_id ) {
$shipment_id = $label['id'];
break;
}
}
if ( null === $shipment_id ) {
continue;
}
// Get the selected origins and match by shipment ID.
$origins = $selected_origins[ $order_id ] ?? null;
if ( ! is_array( $origins ) ) {
continue;
}
$origin_key = 'shipment_' . $shipment_id;
if ( isset( $origins[ $origin_key ] ) ) {
return $origins[ $origin_key ];
}
}
return null;
}
/**
* Validate and extract label IDs from a REST request.
*
* @param WP_REST_Request $request Request object.
*
* @return array|WP_Error Array of label IDs or WP_Error if validation fails.
*/
public function validate_label_ids( WP_REST_Request $request ) {
$params = $request->get_json_params();
$label_ids = isset( $params['label_ids'] ) ? $params['label_ids'] : array();
if ( empty( $label_ids ) ) {
return new WP_Error(
'no_labels',
__( 'No labels provided', 'woocommerce-shipping' ),
array( 'status' => 400 )
);
}
return $label_ids;
}
/**
* Parse ScanForm error message to extract failed label IDs.
*
* Example errors:
* - "Already manifested (9): Label ID: 54321 (Shipment: shp_xxx), Label ID: 54322 (Shipment: shp_yyy)"
* - "No valid labels found. Labels must have EasyPost shipment IDs (shp_*) and be less than 90 days old."
*
* @param string $error_message The error message from the API.
* @param array $submitted_labels All label IDs that were submitted.
*
* @return array Contains 'failed_labels' and 'valid_labels' arrays.
*/
public function parse_scan_form_error( string $error_message, array $submitted_labels ): array {
$failed_labels = array();
// Check if error is "No valid labels found" - all submitted labels are invalid.
if ( stripos( $error_message, 'No valid labels found' ) !== false ) {
return array(
'failed_labels' => array_values( $submitted_labels ),
'valid_labels' => array(),
);
}
// Extract label IDs from error message (pattern: "Label ID: 12345").
if ( preg_match_all( '/Label ID:\s*(\d+)/', $error_message, $matches ) ) {
// $matches[1] contains the captured label IDs.
$failed_labels = array_map( 'intval', $matches[1] );
// Only include labels that were actually submitted.
$failed_labels = array_intersect( $failed_labels, $submitted_labels );
}
// Determine valid labels (submitted minus failed).
$valid_labels = array_diff( $submitted_labels, $failed_labels );
return array(
'failed_labels' => array_values( $failed_labels ),
'valid_labels' => array_values( $valid_labels ),
);
}
/**
* Check if a label is an envelope shipment.
*
* Envelope labels (is_letter = true) are created via PC Postage and are not
* compatible with USPS SCAN Forms. This is a temporary limitation while
* USPS Ship adds envelope support.
*
* @param array $label Label data.
*
* @return bool True if the label is an envelope shipment.
*/
public function is_envelope_label( array $label ): bool {
return ! empty( $label['is_letter'] );
}
/**
* Check if a label is eligible for ScanForm creation.
*
* Based on EasyPost requirements:
* - Label must be for USPS carrier
* - Label must not be refunded
* - Label must have a tracking number
* - Label must be in created/purchased status
*
* Note: Shipping date check (10 days) is done separately in the main loops.
*
* @param array $label Label data.
*
* @return bool True if eligible, false otherwise.
*/
public function is_label_eligible( array $label ): bool {
// Must have the 'PURCHASED' status.
if ( ! isset( $label['status'] ) || 'purchased' !== strtolower( $label['status'] ) ) {
return false;
}
// Must be USPS.
if ( ! isset( $label['carrier_id'] ) || 'usps' !== strtolower( $label['carrier_id'] ) ) {
return false;
}
// Must have tracking number.
if ( empty( $label['tracking'] ) ) {
return false;
}
// Must not be refunded.
if ( isset( $label['refund'] ) ) {
return false;
}
return true;
}
/**
* Fetch meta data for multiple orders in bulk using wpdb.
*
* @param array $order_ids Array of order IDs.
* @param string $meta_key Meta key to fetch.
*
* @return array Associative array with order_id as key and unserialized meta_value as value.
*/
public function get_order_meta_bulk( array $order_ids, string $meta_key ): array {
if ( empty( $order_ids ) ) {
return array();
}
global $wpdb;
$meta_table = OrderUtil::get_table_for_order_meta();
$is_hpos = OrderUtil::custom_orders_table_usage_is_enabled();
$meta_join_column = $is_hpos ? 'order_id' : 'post_id';
// Prepare placeholders for IN clause.
$placeholders = implode( ',', array_fill( 0, count( $order_ids ), '%d' ) );
// Query to fetch meta data for all order IDs at once.
$query = $wpdb->prepare(
'SELECT %i as order_id, meta_value FROM %i WHERE meta_key = %s AND %i IN (' . $placeholders . ')', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$meta_join_column,
$meta_table,
$meta_key,
$meta_join_column,
...array_values( $order_ids )
);
$results = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
// Build associative array with order_id as key.
$meta_data = array();
foreach ( $results as $row ) {
$meta_data[ $row->order_id ] = maybe_unserialize( $row->meta_value );
}
return $meta_data;
}
/**
* Get eligible shipments for orders with shipment dates today or later.
*
* Queries orders with _wcshipping_shipment_dates meta and returns a map of
* order IDs to their eligible shipments (shipment_key => shipment_data) where
* the shipment has a shipping_date >= today.
*
* Excludes labels from trashed orders.
*
* @return array Associative array: [ order_id => [ shipment_key => shipment_data ] ].
*/
public function get_orders_with_eligible_shipping_dates(): array {
global $wpdb;
// Get the appropriate table and column names based on HPOS/legacy.
$is_hpos = OrderUtil::custom_orders_table_usage_is_enabled();
$meta_table = OrderUtil::get_table_for_order_meta();
$meta_join_column = $is_hpos ? 'order_id' : 'post_id';
// Set up table and column names based on HPOS or legacy mode.
$orders_table = OrderUtil::get_table_for_orders();
$orders_id_column = $is_hpos ? 'id' : 'ID';
$status_column = $is_hpos ? 'status' : 'post_status';
// Query meta table for _wcshipping_shipment_dates joined with wcshipping_labels, excluding trashed orders.
$query = $wpdb->prepare(
'SELECT m1.%i as order_id, m1.meta_value as shipment_dates, m2.meta_value as wcshipping_labels
FROM %i m1
INNER JOIN %i m2 ON m1.%i = m2.%i AND m2.meta_key = %s
INNER JOIN %i o ON m1.%i = o.%i
WHERE m1.meta_key = %s AND o.%i != %s
ORDER BY m1.%i DESC',
$meta_join_column,
$meta_table,
$meta_table,
$meta_join_column,
$meta_join_column,
'wcshipping_labels',
$orders_table,
$meta_join_column,
$orders_id_column,
'_wcshipping_shipment_dates',
$status_column,
'trash',
$meta_join_column
);
$results = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
if ( empty( $results ) ) {
return array();
}
/**
* Timestamp for the earliest allowed shipment date.
* Using UTC timezone to align with EasyPost server.
*
* Filters can change this value to set a custom minimum date for
* validations or process rules that depend on the shipment date.
*
* @param int $timestamp Unix timestamp that defines the minimum shipment date.
*/
$today_utc = new DateTime( 'today 00:00:00', new DateTimeZone( 'UTC' ) );
$min_date = apply_filters( 'wcshipping_usps_scanform_min_shipment_date_timestamp', $today_utc->getTimestamp() );
$eligible_by_order = array(); // order_id => [ shipment_key => shipment_data ].
// Collect shipments with shipping_date >= today, keyed by order and shipment key.
foreach ( $results as $row ) {
$shipment_dates = maybe_unserialize( $row->shipment_dates );
$shipping_labels = maybe_unserialize( $row->wcshipping_labels );
if ( empty( $shipment_dates ) || ! is_array( $shipment_dates ) ) {
continue;
}
if ( empty( $shipping_labels ) || ! is_array( $shipping_labels ) ) {
continue;
}
foreach ( $shipment_dates as $shipment_key => $shipment_data ) {
if ( ! is_array( $shipment_data ) || empty( $shipment_data['shipping_date'] ) ) {
continue;
}
$shipping_date = strtotime( $shipment_data['shipping_date'] );
if ( false !== $shipping_date && $shipping_date >= $min_date ) {
$order_id = (int) $row->order_id;
if ( ! isset( $eligible_by_order[ $order_id ] ) ) {
$eligible_by_order[ $order_id ] = array();
}
$eligible_by_order[ $order_id ][ $shipment_key ] = $shipment_data;
}
}
}
return $eligible_by_order;
}
/**
* Save ScanForm information to orders that contain the labels.
*
* @param array $scan_form_data ScanForm data including ID, PDF URL, and creation date.
* @param array $order_labels Array from server with order-to-label mapping.
* Format: [{order_id: 123, label_ids:[12,23,34]}, ...].
*
* @return void
*/
public function save_scan_form_to_orders( array $scan_form_data, array $order_labels ) {
if ( empty( $scan_form_data ) || empty( $order_labels ) ) {
return;
}
foreach ( $order_labels as $order_label_data ) {
// Convert object to array if needed.
$order_label_array = (array) $order_label_data;
$order_id = $order_label_array['order_id'] ?? null;
$label_ids = $order_label_array['label_ids'] ?? array();
if ( empty( $order_id ) || empty( $label_ids ) ) {
continue;
}
$order = wc_get_order( $order_id );
if ( ! $order ) {
continue;
}
// Get existing ScanForms or initialize empty array.
$scan_forms = $order->get_meta( '_wcshipping_scan_forms', true );
if ( ! is_array( $scan_forms ) ) {
$scan_forms = array();
}
// Add the new ScanForm with labels from this order.
$scan_forms[] = array(
'scan_form_id' => $scan_form_data['scan_form_id'],
'pdf_url' => $scan_form_data['pdf_url'],
'created' => $scan_form_data['created'],
'label_ids' => array_values( $label_ids ),
);
// Save to order meta.
$order->update_meta_data( '_wcshipping_scan_forms', $scan_forms );
$order->save();
}
// Clear the ScanForm history cache so the next request fetches fresh data.
$this->clear_history_cache();
}
/**
* Generate a unique key for an origin address based on its address components.
*
* @param array $address Origin address data.
* @return string MD5 hash of the address components.
*/
public function get_origin_address_key( array $address ): string {
// Use address components to generate a unique hash.
$key_parts = array(
strtolower( trim( $address['address'] ?? '' ) ),
strtolower( trim( $address['address_2'] ?? '' ) ),
strtolower( trim( $address['city'] ?? '' ) ),
strtoupper( trim( $address['state'] ?? '' ) ),
strtolower( trim( $address['postcode'] ?? '' ) ),
strtoupper( trim( $address['country'] ?? '' ) ),
);
return md5( implode( '|', $key_parts ) );
}
}