Created
May 11, 2025 17:33
-
-
Save sirbrillig/813e2c97065bd5f712a3a6b6846cb17b to your computer and use it in GitHub Desktop.
A PHP function to perform date interval math in a reliable way for billing systems.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php declare( strict_types=1 ); | |
final class Billing_Date_Interval { | |
const INTERVAL_ONE_DAY = 'day'; | |
const INTERVAL_ONE_MONTH = 'month'; | |
const INTERVAL_ONE_YEAR = 'year'; | |
/** | |
* Add or subtract an interval to a date. | |
* | |
* Adds an interval to a date, while maintaining consistent logic for | |
* nonexistent dates (like Feb 30th). Will attempt to maintain day of month | |
* from original date (like March 30th, for a subscription originated on | |
* January 30th). | |
* | |
* Can also be used to subtract a date interval if the `interval_count` is | |
* negative. | |
* | |
* @param int $interval_count Number of units to add. | |
* @param string $interval_unit Type of interval - 'day', 'month', 'year', '2 years', '3 years'. | |
* @param int $starting_timestamp Timestamp you're adding to. | |
* @param int|null $original_timestamp If this is adding eg: a month to a subscription, we need to know the original date. | |
* | |
* @return int Adjusted timestamp. | |
* @throws Exception | |
*/ | |
public static function apply_interval_to_timestamp( | |
int $interval_count, | |
string $interval_unit, | |
int $starting_timestamp, | |
?int $original_timestamp = null, | |
): int { | |
if ( 0 === $interval_count ) { | |
return $starting_timestamp; | |
} | |
$interval_unit = self::normalize_bill_period( $interval_unit ); | |
$date = new \DateTimeImmutable(); | |
$date = $date->setTimestamp( $starting_timestamp ); | |
$is_start_date_leap_day = $date->format( 'm-d' ) === '02-29'; | |
// Use the original day of the month if the day of the month is likely to | |
// be near the end of the month. | |
$is_original_timestamp_end_of_month = $original_timestamp ? intval( gmdate( 'j', $original_timestamp ) ) > 28 : false; | |
$is_start_date_end_of_month = intval( gmdate( 'j', $starting_timestamp ) ) >= 28; | |
$original_day_in_month = $is_original_timestamp_end_of_month && $is_start_date_end_of_month | |
? intval( gmdate( 'j', $original_timestamp ) ) | |
: null; | |
// Days are simple | |
if ( 'day' === $interval_unit && $interval_count > 0 ) { | |
return $date->modify( '+' . abs( $interval_count ) . ' day' )->getTimestamp(); | |
} | |
if ( 'day' === $interval_unit && $interval_count < 0 ) { | |
return $date->modify( '-' . abs( $interval_count ) . ' day' )->getTimestamp(); | |
} | |
// Years are mostly simple, but we have to account for leap years which | |
// have a Febrary 29. | |
if ( self::INTERVAL_ONE_YEAR === $interval_unit && $interval_count > 0 ) { | |
$day_in_month = (int) $date->format( 'j' ); | |
$new_date = $date->modify( '+' . abs( $interval_count ) . ' year' ); | |
$is_new_date_in_march = $new_date->format( 'm-d' ) === '03-01'; | |
if ( $is_start_date_leap_day && $is_new_date_in_march ) { | |
$new_date = $new_date->modify( '-1 days' ); | |
} | |
$new_date = $new_date->setTimestamp( strtotime( $new_date->format( 'Y-m-01' ) ) ); | |
$total_days_in_month = (int) $new_date->format( 't' ); | |
$days_to_move_in_month = min( $day_in_month, $total_days_in_month ); | |
return $new_date->modify( '+' . $days_to_move_in_month - 1 . ' days' )->getTimestamp(); | |
} | |
if ( self::INTERVAL_ONE_YEAR === $interval_unit && $interval_count < 0 ) { | |
$day_in_month = (int) $date->format( 'j' ); | |
$new_date = $date->modify( '-' . abs( $interval_count ) . ' year' ); | |
$is_new_date_leap_day = $new_date->format( 'm-d' ) === '02-29'; | |
if ( $is_start_date_leap_day && ! $is_new_date_leap_day ) { | |
$new_date = $new_date->modify( '-1 days' ); | |
} | |
$new_date = $new_date->setTimestamp( strtotime( $new_date->format( 'Y-m-01' ) ) ); | |
$total_days_in_month = (int) $new_date->format( 't' ); | |
$days_to_move_in_month = min( $day_in_month, $total_days_in_month ); | |
return $new_date->modify( '+' . $days_to_move_in_month - 1 . ' days' )->getTimestamp(); | |
} | |
// Months need to never go past the month boundary. PHP treats "1 month" as | |
// a number of days. To PHP, January 31 plus "1 month" is March 2, but we | |
// want it to be Febrary 28. To PHP, December 31 minus "1 month" is | |
// December 1, but we want it to be November 30. | |
if ( self::INTERVAL_ONE_MONTH === $interval_unit && $interval_count > 0 ) { | |
$day_in_month = (int) $date->format( 'j' ); | |
if ( is_int( $original_day_in_month ) ) { | |
$day_in_month = $original_day_in_month; | |
} | |
$date = $date->modify( 'first day of +' . $interval_count . ' months' ); | |
$total_days_in_month = (int) $date->format( 't' ); | |
$days_to_move_in_month = min( $day_in_month, $total_days_in_month ); | |
return $date->modify( '+' . $days_to_move_in_month - 1 . ' days' )->getTimestamp(); | |
} | |
if ( self::INTERVAL_ONE_MONTH === $interval_unit && $interval_count < 0 ) { | |
$day_in_month = (int) $date->format( 'j' ); | |
if ( is_int( $original_day_in_month ) ) { | |
$day_in_month = $original_day_in_month; | |
} | |
$date = $date->modify( 'first day of -' . abs( $interval_count ) . ' months' ); | |
$total_days_in_month = (int) $date->format( 't' ); | |
$days_to_move_in_month = min( $day_in_month, $total_days_in_month ); | |
return $date->modify( '+' . $days_to_move_in_month - 1 . ' days' )->getTimestamp(); | |
} | |
throw new \Exception( sprintf( 'Failed to adjust timestamp by %d %s', esc_html( $interval_count ), esc_html( $interval_unit ) ) ); | |
} | |
private static function normalize_bill_period( string $bill_period ): string { | |
switch ( $bill_period ) { | |
case 'days': | |
case 'day': | |
return self::INTERVAL_ONE_DAY; | |
case 'months': | |
case 'month': | |
return self::INTERVAL_ONE_MONTH; | |
case 'years': | |
case 'year': | |
return self::INTERVAL_ONE_YEAR; | |
} | |
throw new \Exception( sprintf( 'Unknown bill period: %s', esc_html( $bill_period ) ) ); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment