true, 'value' => array ) or array( 'ok' => false, 'errors' => array( field => message ) ). */ final class Validator { const NAME_MAX = 80; const UNIT_MAX = 16; const MAX_CENTS = 2000000000; const MAX_QUANTITY = 1000000000; const MAX_WASTE_PERCENT = 500; const MAX_MINUTES = 100000; const MAX_RECIPE_LINES = 100; const MAX_TARGET_MARGIN = 95; const PRICE_ROUNDINGS = array( 'none', 'whole', 'end99' ); /** * 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; } /** * Whether the value is a finite number inside the range. * * @param mixed $value Value to check. * @param float $min Smallest allowed. * @param float $max Largest allowed. */ public static function is_num_in( $value, $min, $max ): bool { return ( is_int( $value ) || is_float( $value ) ) && is_finite( (float) $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 ); } /** * Check a material. * * @param mixed $input Decoded JSON. */ public static function material( $input ): array { $input = is_array( $input ) ? $input : array(); $errors = array(); $name = self::clean( $input['name'] ?? '' ); $unit = self::clean( $input['unit'] ?? '' ); $price = $input['packPriceCents'] ?? null; $qty = $input['packQuantity'] ?? null; $waste = $input['wastePercent'] ?? null; if ( '' === $name ) { $errors['name'] = __( 'Enter a name.', 'madecost' ); } elseif ( self::length( $name ) > self::NAME_MAX ) { /* translators: %d: maximum number of characters */ $errors['name'] = sprintf( __( 'Use at most %d characters.', 'madecost' ), self::NAME_MAX ); } if ( '' === $unit ) { $errors['unit'] = __( 'Enter a unit (g, ml, pc...).', 'madecost' ); } elseif ( self::length( $unit ) > self::UNIT_MAX ) { /* translators: %d: maximum number of characters */ $errors['unit'] = sprintf( __( 'Use at most %d characters.', 'madecost' ), self::UNIT_MAX ); } if ( ! self::is_int_in( $price, 0, self::MAX_CENTS ) ) { $errors['packPriceCents'] = __( 'Enter the price of one pack (0 or more).', 'madecost' ); } if ( ! ( self::is_num_in( $qty, 0, self::MAX_QUANTITY ) && $qty > 0 ) ) { $errors['packQuantity'] = __( 'Enter how much one pack contains (more than 0).', 'madecost' ); } if ( ! self::is_num_in( $waste, 0, self::MAX_WASTE_PERCENT ) ) { /* translators: %d: maximum waste percentage */ $errors['wastePercent'] = sprintf( __( 'Enter a waste between 0 and %d %%.', 'madecost' ), self::MAX_WASTE_PERCENT ); } if ( $errors ) { return array( 'ok' => false, 'errors' => $errors, ); } return array( 'ok' => true, 'value' => array( 'name' => $name, 'unit' => $unit, 'packPriceCents' => (int) $price, 'packQuantity' => (float) $qty, 'wastePercent' => (float) $waste, ), ); } /** * Check a recipe. The product itself is checked by the Recipes store. * * @param mixed $input Decoded JSON. */ public static function recipe( $input ): array { $input = is_array( $input ) ? $input : array(); $errors = array(); $lines = $input['lines'] ?? null; if ( ! is_array( $lines ) ) { $errors['lines'] = __( 'The recipe has no list of materials.', 'madecost' ); $lines = array(); } elseif ( count( $lines ) > self::MAX_RECIPE_LINES ) { /* translators: %d: maximum number of lines */ $errors['lines'] = sprintf( __( 'A recipe can have at most %d materials.', 'madecost' ), self::MAX_RECIPE_LINES ); } else { $ids = array(); foreach ( $lines as $line ) { $material_id = is_array( $line ) ? ( $line['materialId'] ?? '' ) : ''; $quantity = is_array( $line ) ? ( $line['quantity'] ?? null ) : null; if ( ! is_string( $material_id ) || '' === $material_id ) { $errors['lines'] = __( 'Choose a material on every line.', 'madecost' ); break; } if ( ! ( self::is_num_in( $quantity, 0, self::MAX_QUANTITY ) && $quantity > 0 ) ) { $errors['lines'] = __( 'Every quantity must be more than 0.', 'madecost' ); break; } $ids[] = $material_id; } if ( ! isset( $errors['lines'] ) && count( array_unique( $ids ) ) !== count( $ids ) ) { $errors['lines'] = __( 'Each material can appear only once.', 'madecost' ); } } $labour = $input['labourMinutes'] ?? null; $packaging = $input['packagingCents'] ?? null; $other = $input['otherCents'] ?? null; if ( ! self::is_num_in( $labour, 0, self::MAX_MINUTES ) ) { $errors['labourMinutes'] = __( 'Enter the labour time in minutes (0 or more).', 'madecost' ); } if ( ! self::is_int_in( $packaging, 0, self::MAX_CENTS ) ) { $errors['packagingCents'] = __( 'Enter the packaging cost (0 or more).', 'madecost' ); } if ( ! self::is_int_in( $other, 0, self::MAX_CENTS ) ) { $errors['otherCents'] = __( 'Enter the other costs (0 or more).', 'madecost' ); } if ( $errors ) { return array( 'ok' => false, 'errors' => $errors, ); } $clean_lines = array(); foreach ( $lines as $line ) { $clean_lines[] = array( 'materialId' => $line['materialId'], 'quantity' => (float) $line['quantity'], ); } return array( 'ok' => true, 'value' => array( 'lines' => $clean_lines, 'labourMinutes' => (float) $labour, 'packagingCents' => (int) $packaging, 'otherCents' => (int) $other, ), ); } /** * Check the settings. * * @param mixed $input Decoded JSON. */ public static function settings( $input ): array { $input = is_array( $input ) ? $input : array(); $errors = array(); $hourly = $input['hourlyRateCents'] ?? null; $margin = $input['targetMarginPercent'] ?? null; $rounding = $input['priceRounding'] ?? null; $delete = $input['deleteDataOnUninstall'] ?? false; if ( ! self::is_int_in( $hourly, 0, self::MAX_CENTS ) ) { $errors['hourlyRateCents'] = __( 'Enter your hourly rate (0 or more).', 'madecost' ); } if ( ! self::is_num_in( $margin, 0, self::MAX_TARGET_MARGIN ) ) { /* translators: %d: maximum margin percentage */ $errors['targetMarginPercent'] = sprintf( __( 'Enter a margin between 0 and %d %%.', 'madecost' ), self::MAX_TARGET_MARGIN ); } if ( ! in_array( $rounding, self::PRICE_ROUNDINGS, true ) ) { $errors['priceRounding'] = __( 'Choose how prices are rounded.', 'madecost' ); } if ( ! is_bool( $delete ) ) { $errors['deleteDataOnUninstall'] = __( 'Choose yes or no.', 'madecost' ); } if ( $errors ) { return array( 'ok' => false, 'errors' => $errors, ); } return array( 'ok' => true, 'value' => array( 'hourlyRateCents' => (int) $hourly, 'targetMarginPercent' => (float) $margin, 'priceRounding' => $rounding, 'deleteDataOnUninstall' => (bool) $delete, ), ); } }