setTimezone( self::site_tz() ) : null; } /** * Formats a DateTimeImmutable as a GMT 'Y-m-d H:i:s' string. * * @param \DateTimeImmutable $date Date. * @return string */ public static function to_gmt( \DateTimeImmutable $date ) { return $date->setTimezone( new \DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ); } /** * Parses a site-timezone 'Y-m-d H:i' string. * * @param string $local Local string. * @return \DateTimeImmutable|null */ public static function from_site( $local ) { $date = \DateTimeImmutable::createFromFormat( 'Y-m-d H:i', $local, self::site_tz() ); return $date ? $date : null; } /** * Shifts by days and calendar months (month arithmetic clamps to the month's last day). * * @param \DateTimeImmutable $date Date. * @param int $days Days (signed). * @param int $months Months (signed). * @return \DateTimeImmutable */ public static function shift( \DateTimeImmutable $date, $days = 0, $months = 0 ) { if ( 0 !== (int) $months ) { $day = (int) $date->format( 'j' ); $first = $date->setDate( (int) $date->format( 'Y' ), (int) $date->format( 'n' ), 1 ); $moved = $first->modify( sprintf( '%+d months', (int) $months ) ); $last = (int) $moved->format( 't' ); $date = $moved->setDate( (int) $moved->format( 'Y' ), (int) $moved->format( 'n' ), min( $day, $last ) ); } if ( 0 !== (int) $days ) { $date = $date->modify( sprintf( '%+d days', (int) $days ) ); } return $date; } /** * Moves a date to a given day of the month, keeping (or zeroing) the time of day, and rolling * forward month by month until it is at least $lead_hours after $now. * * @param \DateTimeImmutable $current Current date (site tz). * @param int|string $day 1..31 or 'last'. * @param \DateTimeImmutable $now Now (site tz). * @param bool $preserve_time Keep the time of day. * @param int $lead_hours Minimum lead. * @return \DateTimeImmutable */ public static function anchor_to_day( \DateTimeImmutable $current, $day, \DateTimeImmutable $now, $preserve_time = true, $lead_hours = 24 ) { $candidate = $preserve_time ? $current : $current->setTime( 0, 0, 0 ); $candidate = self::set_day( $candidate, $day ); $floor = $now->modify( sprintf( '+%d hours', max( 0, (int) $lead_hours ) ) ); $guard = 0; while ( $candidate < $floor && $guard++ < 24 ) { $candidate = self::set_day( self::shift( $candidate->setDate( (int) $candidate->format( 'Y' ), (int) $candidate->format( 'n' ), 1 ), 0, 1 ), $day ); if ( ! $preserve_time ) { $candidate = $candidate->setTime( 0, 0, 0 ); } else { $candidate = $candidate->setTime( (int) $current->format( 'G' ), (int) $current->format( 'i' ), (int) $current->format( 's' ) ); } } return $candidate; } /** * Sets the day of month with clamping. * * @param \DateTimeImmutable $date Date. * @param int|string $day 1..31 or 'last'. * @return \DateTimeImmutable */ public static function set_day( \DateTimeImmutable $date, $day ) { $last = (int) $date->format( 't' ); $d = 'last' === $day ? $last : min( max( 1, (int) $day ), $last ); return $date->setDate( (int) $date->format( 'Y' ), (int) $date->format( 'n' ), $d ); } /** * Validates subscription date ordering. Returns an error code or null. * * @param array $ts start, trial_end, next_payment, end as timestamps (0 = none). * @return string|null */ public static function validate_order( array $ts ) { $start = isset( $ts['start'] ) ? (int) $ts['start'] : 0; $trial = isset( $ts['trial_end'] ) ? (int) $ts['trial_end'] : 0; $next = isset( $ts['next_payment'] ) ? (int) $ts['next_payment'] : 0; $end = isset( $ts['end'] ) ? (int) $ts['end'] : 0; if ( $trial && $start && $trial < $start ) { return 'trial_before_start'; } if ( $next && $trial && $next < $trial ) { return 'next_payment_before_trial_end'; } if ( $next && $start && $next < $start ) { return 'next_payment_before_start'; } if ( $end && $next && $end <= $next ) { return 'end_not_after_next_payment'; } if ( $end && $trial && $end <= $trial ) { return 'end_not_after_trial_end'; } return null; } /** * Human message for a validate_order() code. * * @param string $code Code. * @return string */ public static function order_message( $code ) { $messages = array( 'trial_before_start' => __( 'The trial end is before the start date.', 'recast-subscriptions' ), 'next_payment_before_trial_end' => __( 'The next payment is before the trial end.', 'recast-subscriptions' ), 'next_payment_before_start' => __( 'The next payment is before the start date.', 'recast-subscriptions' ), 'end_not_after_next_payment' => __( 'The end date must be after the next payment.', 'recast-subscriptions' ), 'end_not_after_trial_end' => __( 'The end date must be after the trial end.', 'recast-subscriptions' ), ); return isset( $messages[ $code ] ) ? $messages[ $code ] : $code; } }