Skip to content

Instantly share code, notes, and snippets.

@sirbrillig
Created May 11, 2025 17:33
Show Gist options
  • Save sirbrillig/813e2c97065bd5f712a3a6b6846cb17b to your computer and use it in GitHub Desktop.
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.
<?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