true, 'value' => array ) or array( 'ok' => false, 'errors' => array( field => message ) ). */ final class Validator { const NAME_MAX = 80; const SOURCE_MAX = 80; const TITLE_MAX = 255; const MAX_CENTS = 2000000000; const MAX_UNITS = 100000; const MAX_PIECES = 500; const WOO_ID = '/^[1-9][0-9]{0,18}$/'; const METHODS = array( 'equal', 'price', 'manual' ); /** * Whether the value is a whole number inside the range (JSON may hand us 1500.0). * * @param mixed $value Value to check. * @param float $min Smallest allowed. * @param float $max Largest allowed. */ public static function is_int_in( $value, $min, $max ): bool { if ( is_float( $value ) ) { if ( floor( $value ) !== $value ) { return false; } $value = (int) $value; } return is_int( $value ) && $value >= $min && $value <= $max; } /** * Trim and collapse white space. * * @param mixed $text Text to clean. */ public static function clean( $text ): string { if ( ! is_string( $text ) ) { return ''; } return trim( (string) preg_replace( '/\s+/u', ' ', $text ) ); } /** * Length in characters. * * @param string $text Text to measure. */ private static function length( string $text ): int { return function_exists( 'mb_strlen' ) ? mb_strlen( $text ) : strlen( $text ); } /** * Whether the text is a real calendar day like 2026-10-14. * * @param string $text Text to check. */ public static function is_calendar_date( string $text ): bool { if ( ! preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $text, $match ) ) { return false; } return checkdate( (int) $match[2], (int) $match[3], (int) $match[1] ); } /** * Check a lot, pieces included. Whether the products exist is checked by the Lots store. * * @param mixed $input Decoded JSON. */ public static function lot( $input ): array { $input = is_array( $input ) ? $input : array(); $errors = array(); $name = self::clean( $input['name'] ?? '' ); $source = self::clean( $input['source'] ?? '' ); $purchased_at = isset( $input['purchasedAt'] ) && is_string( $input['purchasedAt'] ) ? trim( $input['purchasedAt'] ) : ''; $method = $input['method'] ?? null; if ( '' === $name ) { $errors['name'] = __( 'Enter a name for the lot.', 'lotcost' ); } elseif ( self::length( $name ) > self::NAME_MAX ) { /* translators: %d: maximum number of characters */ $errors['name'] = sprintf( __( 'Use at most %d characters.', 'lotcost' ), self::NAME_MAX ); } if ( self::length( $source ) > self::SOURCE_MAX ) { /* translators: %d: maximum number of characters */ $errors['source'] = sprintf( __( 'Use at most %d characters.', 'lotcost' ), self::SOURCE_MAX ); } if ( '' !== $purchased_at && ! self::is_calendar_date( $purchased_at ) ) { $errors['purchasedAt'] = __( 'Enter a date like 2026-10-14, or leave it empty.', 'lotcost' ); } if ( ! self::is_int_in( $input['itemsCents'] ?? null, 0, self::MAX_CENTS ) ) { $errors['itemsCents'] = __( 'Enter the price you paid for the lot (0 or more).', 'lotcost' ); } if ( ! self::is_int_in( $input['shippingCents'] ?? null, 0, self::MAX_CENTS ) ) { $errors['shippingCents'] = __( 'Enter the shipping cost (0 or more).', 'lotcost' ); } if ( ! self::is_int_in( $input['otherCents'] ?? null, 0, self::MAX_CENTS ) ) { $errors['otherCents'] = __( 'Enter the other costs (0 or more).', 'lotcost' ); } if ( ! is_string( $method ) || ! in_array( $method, self::METHODS, true ) ) { $errors['method'] = __( 'Choose how the cost is shared.', 'lotcost' ); } $pieces = array(); $raw = $input['pieces'] ?? null; if ( ! is_array( $raw ) ) { $errors['pieces'] = __( 'The lot has no list of pieces.', 'lotcost' ); } elseif ( count( $raw ) > self::MAX_PIECES ) { /* translators: %d: maximum number of pieces */ $errors['pieces'] = sprintf( __( 'A lot can have at most %d pieces.', 'lotcost' ), self::MAX_PIECES ); } else { $seen = array(); foreach ( $raw as $piece ) { $piece = is_array( $piece ) ? $piece : array(); $title = self::clean( $piece['title'] ?? '' ); $product_id = $piece['productId'] ?? null; $variant_id = $piece['variantId'] ?? null; $pinned = $piece['pinnedCents'] ?? null; $manual = $piece['manualCents'] ?? null; $problem = null; if ( ! is_string( $product_id ) || ! preg_match( self::WOO_ID, $product_id ) || ! is_string( $variant_id ) || ! preg_match( self::WOO_ID, $variant_id ) ) { $problem = __( 'This is not a product of your store.', 'lotcost' ); } elseif ( '' === $title ) { $problem = __( 'The product has no title.', 'lotcost' ); } elseif ( self::length( $title ) > self::TITLE_MAX ) { /* translators: %d: maximum number of characters */ $problem = sprintf( __( 'Use at most %d characters for the title.', 'lotcost' ), self::TITLE_MAX ); } elseif ( ! self::is_int_in( $piece['units'] ?? null, 1, self::MAX_UNITS ) ) { /* translators: %d: maximum number of units */ $problem = sprintf( __( 'Enter a whole number of units (1 to %d).', 'lotcost' ), self::MAX_UNITS ); } elseif ( null !== $pinned && ! self::is_int_in( $pinned, 0, self::MAX_CENTS ) ) { $problem = __( 'Enter a fixed cost of 0 or more.', 'lotcost' ); } elseif ( null !== $manual && ! self::is_int_in( $manual, 0, self::MAX_CENTS ) ) { $problem = __( 'Enter a cost of 0 or more.', 'lotcost' ); } elseif ( isset( $seen[ $variant_id ] ) ) { $problem = __( 'This piece is in the lot twice.', 'lotcost' ); } if ( null !== $problem ) { $errors['pieces'] = '' !== $title ? $title . ': ' . $problem : $problem; break; } $seen[ $variant_id ] = true; $pieces[] = array( 'productId' => $product_id, 'variantId' => $variant_id, 'title' => $title, 'units' => (int) $piece['units'], 'pinnedCents' => null === $pinned ? null : (int) $pinned, 'manualCents' => null === $manual ? null : (int) $manual, ); } } if ( $errors ) { return array( 'ok' => false, 'errors' => $errors, ); } return array( 'ok' => true, 'value' => array( 'name' => $name, 'source' => $source, 'purchasedAt' => $purchased_at, 'itemsCents' => (int) $input['itemsCents'], 'shippingCents' => (int) $input['shippingCents'], 'otherCents' => (int) $input['otherCents'], 'method' => $method, 'confirmed' => isset( $input['confirmed'] ) && true === $input['confirmed'], 'pieces' => $pieces, ), ); } /** * Check the settings. * * @param mixed $input Decoded JSON. */ public static function settings( $input ): array { $input = is_array( $input ) ? $input : array(); $delete = $input['deleteDataOnUninstall'] ?? false; if ( ! is_bool( $delete ) ) { return array( 'ok' => false, 'errors' => array( 'deleteDataOnUninstall' => __( 'Choose yes or no.', 'lotcost' ) ), ); } return array( 'ok' => true, 'value' => array( 'deleteDataOnUninstall' => $delete ), ); } }