DateFieldType.php
<?php
declare( strict_types = 1);
namespace Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldTypes;
use Automattic\WooCommerce\Utilities\TimeUtil;
use WP_Error;
/**
* The "date" additional checkout field type.
*
* Values are calendar dates in YYYY-MM-DD format with no time or timezone component. Field
* also accept min/max constraints as absolute dates (i.e. YYYY-MM-DD) or relative
* ISO 8601-2 periods (e.g. -P2Y, P1M).
*/
class DateFieldType extends AbstractFieldType {
/**
* Matches a YYYY-MM-DD date with valid month and day ranges.
*
* Undelimited because a JSON Schema pattern takes a bare expression; the PHP uses add delimiters.
*/
private const DATE_PATTERN = '\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])';
/**
* Defaults date comparison schemas to date strings without replacing supplied keywords.
*
* @since 11.2.0
*
* @param array $schema The supplied validation schema.
* @return array The schema with defaults applied.
*/
public function prepare_validation_schema( array $schema ): array {
foreach ( array( 'formatMinimum', 'formatMaximum', 'formatExclusiveMinimum', 'formatExclusiveMaximum' ) as $keyword ) {
if ( array_key_exists( $keyword, $schema ) ) {
return array_merge(
array(
'type' => 'string',
'format' => 'date',
),
$schema
);
}
}
return $schema;
}
/**
* Processes the options for a date field and returns the new field_options array.
*
* @param array $field_data The field data array to be updated.
* @param array $options The options supplied during field registration.
* @return array|false The updated $field_data array, or false if an error was encountered.
*/
protected function process_type_options( array $field_data, array $options ) {
$id = $options['id'];
foreach ( array( 'min', 'max' ) as $constraint ) {
// Both are optional. Drop the key entirely rather than carrying a null through to the client.
if ( ! isset( $options[ $constraint ] ) ) {
unset( $field_data[ $constraint ] );
continue;
}
$value = $options[ $constraint ];
if ( ! is_string( $value ) ) {
return $this->registration_error(
$id,
sprintf( 'The "%s" property of a "date" field must be a date in YYYY-MM-DD format or an ISO 8601-2 duration such as "P1D" or "-P18Y".', $constraint ),
'11.2.0'
);
}
$value = trim( $value );
if ( $this->is_absolute_date( $value ) ) {
if ( null === $this->parse_date( $value ) ) {
return $this->registration_error(
$id,
sprintf( 'The "%s" property of a "date" field must be a real calendar date, and "%s" is not.', $constraint, $value ),
'11.2.0'
);
}
$field_data[ $constraint ] = $value;
continue;
}
// DateInterval implements ISO 8601-1, which has no sign, so the ISO 8601-2 sign is peeled off first.
$sign = 1;
$body = $value;
if ( '' !== $body && in_array( $body[0], array( '+', '-' ), true ) ) {
$sign = '-' === $body[0] ? -1 : 1;
$body = substr( $body, 1 );
}
try {
$interval = self::parse_duration( $body );
} catch ( \Exception $e ) {
return $this->registration_error(
$id,
sprintf( 'The "%s" property of a "date" field must be a date in YYYY-MM-DD format or an ISO 8601-2 duration such as "P1D" or "-P18Y", and "%s" is neither.', $constraint, $value ),
'11.2.0'
);
}
// A date field has no sub-day precision, so a time component means the caller meant something else.
if ( $interval->h || $interval->i || $interval->s || $interval->f ) {
return $this->registration_error(
$id,
sprintf( 'The "%s" property of a "date" field must be a duration in whole days, but "%s" includes a time component.', $constraint, $value ),
'11.2.0'
);
}
// Keep the duration unresolved and drop an explicit plus sign.
$field_data[ $constraint ] = ( -1 === $sign ? '-' : '' ) . $body;
}
return $field_data;
}
/**
* Parses a duration without losing weeks on PHP 7.
*
* @param string $duration The unsigned ISO duration.
* @return \DateInterval The parsed duration.
*/
private static function parse_duration( string $duration ): \DateInterval {
if ( version_compare( PHP_VERSION, '8.0', '<' ) ) {
$duration = preg_replace_callback(
'/(\d+)W(?:(\d+)D)?/',
static function ( $matches ) {
return ( (int) $matches[1] * 7 + (int) ( $matches[2] ?? 0 ) ) . 'D';
},
$duration
) ?? '';
}
return new \DateInterval( $duration );
}
/**
* Trims whitespace from submitted date values before they are validated and stored.
*
* @param mixed $value The submitted value.
* @param array $field The field.
* @return mixed The sanitized value.
*/
public function sanitize( $value, array $field ) {
return is_string( $value ) ? trim( $value ) : $value;
}
/**
* Validates that a submitted value is a real calendar date within the field's min/max constraints.
*
* @param mixed $value The submitted value.
* @param array $field The field.
* @return WP_Error|null Error if the value is not valid, null otherwise.
*/
public function validate( $value, array $field ) {
// An empty value is not a type error. Required fields are handled by the field's validation callback.
if ( null === $value || '' === $value ) {
return null;
}
if ( ! is_string( $value ) || ! TimeUtil::is_valid_date( $value, 'Y-m-d' ) ) {
return new WP_Error(
'woocommerce_invalid_checkout_field',
sprintf(
/* translators: %s: is the field label */
__( 'Please provide a valid %s in YYYY-MM-DD format.', 'woocommerce' ),
$field['label']
)
);
}
[ 'min' => $min, 'max' => $max ] = $this->get_constraints( $field, true );
// Both sides are YYYY-MM-DD, so a string comparison orders them correctly.
if ( $min && $value < $min ) {
return new WP_Error(
'woocommerce_invalid_checkout_field',
sprintf(
/* translators: 1: is the field label, 2: is the earliest date allowed */
__( 'Please provide a %1$s on or after %2$s.', 'woocommerce' ),
$field['label'],
$this->format_value( $min, $field )
)
);
}
if ( $max && $value > $max ) {
return new WP_Error(
'woocommerce_invalid_checkout_field',
sprintf(
/* translators: 1: is the field label, 2: is the latest date allowed */
__( 'Please provide a %1$s on or before %2$s.', 'woocommerce' ),
$field['label'],
$this->format_value( $max, $field )
)
);
}
return null;
}
/**
* Formats a stored YYYY-MM-DD date using the store's date format.
*
* @param mixed $value The stored value.
* @param array $field The field.
* @return mixed The formatted date, or the value unchanged if it could not be parsed.
*/
public function format_value( $value, array $field ) {
$date = is_string( $value ) ? $this->parse_date( $value ) : null;
if ( null === $date ) {
return $value;
}
$formatted = wp_date( wc_date_format(), $date->getTimestamp() );
return false === $formatted ? $value : $formatted;
}
/**
* Adds the resolved min/max constraints as input attributes for woocommerce_form_field().
*
* These forms are rendered server side, so the constraints are resolved here rather than by the client.
*
* @param array $form_field The woocommerce_form_field() arguments built from the field.
* @return array The updated arguments.
*/
public function prepare_form_field( array $form_field ): array {
$form_field['custom_attributes'] = array_merge(
isset( $form_field['custom_attributes'] ) && is_array( $form_field['custom_attributes'] ) ? $form_field['custom_attributes'] : array(),
array_filter( $this->get_constraints( $form_field ) )
);
return $form_field;
}
/**
* Returns the resolved min and max dates for a date field.
*
* @param array $field The field.
* @param bool $log_invalid_range Whether to log an invalid range during value validation.
* @return array Array with "min" and "max" keys, each a YYYY-MM-DD date or null when unconstrained.
*/
private function get_constraints( array $field, bool $log_invalid_range = false ): array {
$constraints = array(
'min' => isset( $field['min'] ) ? $this->resolve_constraint( $field['min'] ) : null,
'max' => isset( $field['max'] ) ? $this->resolve_constraint( $field['max'] ) : null,
);
if ( isset( $constraints['min'], $constraints['max'] ) && $constraints['min'] > $constraints['max'] ) {
if ( $log_invalid_range ) {
wc_get_logger()->warning(
sprintf( 'Date limits for checkout field "%s" were ignored because min (%s) is after max (%s).', $field['id'], $constraints['min'], $constraints['max'] ),
array( 'source' => 'checkout-fields' )
);
}
return array(
'min' => null,
'max' => null,
);
}
return $constraints;
}
/**
* Resolves a date field min/max constraint to a YYYY-MM-DD date in the store's timezone.
*
* Durations are resolved at read time, so they follow the current date rather
* than freezing into whatever markup a page cache stored.
*
* We use DateInterval to resolve durations but it has 2 issues that we must solve here:
* 1. Negative constraints are not supported with this version, so we must account for them ourselves.
* 2. DateInterval has an inconsistent behavior compared to ISO 8601-2 in which adding
* a month to a date equals adding 31 days instead of a calendar month,
* resulting in P1M after Jan 31 giving you Mar 3 instead of Feb 28.
*
* @param string $value The constraint, as validated and normalized at registration.
* @param \DateTimeInterface|null $reference Date a duration is relative to. Defaults to today in the store timezone.
* @return string The resolved date.
*/
private function resolve_constraint( string $value, $reference = null ): string {
if ( $this->is_absolute_date( $value ) ) {
return $value;
}
$sign = '-' === $value[0] ? -1 : 1;
$interval = self::parse_duration( ltrim( $value, '-' ) );
$today = new \DateTimeImmutable( $reference ? $reference->format( 'Y-m-d' ) : 'today', wp_timezone() );
// Handles the DateInterval bug in which months days overflow to the next month instead of staying in the current month.
// P1M to Jan 31 gives you Mar 3 instead of Feb 28.
// So we deconstruct the period into years, months, and days, and then add them back separately.
$months = $sign * ( $interval->y * 12 + $interval->m );
$days = $sign * $interval->d;
// Adding x months to today and asking for the last day of that month, then clamping the day to today if it exceeds it.
$end_of_month = $today->modify( sprintf( 'last day of %+d months', $months ) );
$resolved = $end_of_month->setDate(
(int) $end_of_month->format( 'Y' ),
(int) $end_of_month->format( 'n' ),
// Asking for min will get us the correct end of month if we're about to overflow, or the actual date.
// Because adding P1M to Jan 17 should give you Feb 17 but adding P1M to Jan 28-31 should give you Feb 28 (or Feb 29 on leap years).
min( (int) $today->format( 'j' ), (int) $end_of_month->format( 'j' ) )
);
// Add the days back from the original interval.
return $resolved->modify( sprintf( '%+d days', $days ) )->format( 'Y-m-d' );
}
/**
* Parses a YYYY-MM-DD date in the store's timezone.
*
* @param string $value The date to parse.
* @return \DateTimeImmutable|null The date, or null if it is not a real calendar date.
*/
private function parse_date( string $value ) {
if ( ! TimeUtil::is_valid_date( $value, 'Y-m-d' ) ) {
return null;
}
$date = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value, wp_timezone() );
return $date ? $date : null;
}
/**
* Returns true if the given value is a date in YYYY-MM-DD format.
*
* @param mixed $value The value to check.
* @return bool
*/
private function is_absolute_date( $value ): bool {
return is_string( $value ) && 1 === preg_match( '/^' . self::DATE_PATTERN . '$/', $value );
}
}