Payton.Codes

Musings on life, games, and code

Thirty days hath September

Here’s a riddle for you: what’s one month past January 31?

Is it February 28, the end of the month? Or is it March 2, thirty days ahead? Or March 3, thirty-one days ahead? There’s no correct answer. So if you signed up for a monthly subscription on January 31, or on October 31, or any day which doesn’t exist in the next month, it’s not very clear when you should next be billed. This is true for annual subscriptions as well: what is one year past February 29? Is it February 28 or March 1?

The end of month problem

Today I thought I’d write about a project I just finished at work: standardizing how we perform date interval math for our billing system.

As you can see from the riddle above, common date intervals like “one month” or “one year” don’t have a single definition and so it’s very difficult to determine how they should behave.

That said, programming languages do have opinions. Most of the backend code I work with is written in PHP, which provides the powerful DateTime and DateInterval classes in its standard library. It also includes many convenient date helper functions like strtotime() which operate using the same mechanisms. If you ask PHP what one month past January 31 is, it will tell you,

> gmdate('M d', strtotime('+1 month', strtotime('2025-01-31')))
"Mar 03"

I think I can safely say that for most people, that answer is not when they’d expect to be billed for a monthly subscription. We’d have skipped February entirely!

And this is not limited to the awkwardness of February. What is one month past October 31?

> gmdate('M d', strtotime('+1 month', strtotime('2025-10-31')))
"Dec 01"

So we skip November also. For customers who expect to be charged monthly on the same day, this is a big problem!

Our billing system was sometimes using PHP’s date interval functions, and sometimes a system which tried to stay within the calendar month, and sometimes a system which tried to stay within the calendar month but preserve the day of the month from the original subscription. Different techniques were used all over the place. We needed to standardize.

Three rules

First, keep in mind that while in most of the examples I’ve given so far we only have two pieces of information – the interval (eg: “one month”) and the date we’re extending – in a subscription billing system we actually know three things:

  • The interval.
  • The date we’re extending (the current expiry date of the subscription if this is a renewal).
  • The original start date of the subscription.

Usually the day of the month of the start date and the day of the month of the date we want to extend are the same – if a subscription started on January 5 and one month has already passed, the date we will be extending is February 5 – but sometimes they are not. This can happen because of the end-of-month situations I noted above, but it can also happen because the expiry date of the subscription has been altered manually for some other reason.

For the purposes of billing, my team decided that these are the three rules we want to follow:

  1. If the day of the expiry month exists in the next interval unit (month, year), use that.
    For example, if a monthly subscription’s expiry date is April 5, it will next renew on May 5.

  2. If the day of the expiry month does not exist in the next interval unit, use the last day of the next interval’s month.
    For example, if a monthly subscription’s expiry date is October 31, it will next renew on November 30. Similarly, if an annual subscription’s expiry date is February 29 during a leap year, it will next renew on February 28.

  3. If the day of the expiry month exists in the next interval unit and the subscription started on a different day of the month, and both the expiry day and the subscription start day are near the of their month, use the same day of the month as the original start date.
    For example, if a user purchased an annual subscription on January 31 and its current expiry date is February 28, it will next renew on March 31 (not March 28).

One function to do it all

To apply all three of these rules, I created the function shown below which handles all the cases I mentioned above and also supports negative intervals, like figuring out what day is one month prior to March 31. Here’s how it can be used to answer the questions we explored above: what is one month past January 31?

> gmdate('M d', Billing_Date_Interval::apply_interval_to_timestamp(1, 'month', strtotime('2025-01-31')))
"Feb 28"

And what about one month past October 31?

> gmdate('M d', Billing_Date_Interval::apply_interval_to_timestamp(1, 'month', strtotime('2025-10-31')))
"Nov 30"

That seems much more reasonable. The dates are a lot closer to what most people would probably expect.

And now, without further ado, the full function definition:

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 = match ($interval_unit) {
		self::INTERVAL_ONE_DAY,
		'days'
			=> self::INTERVAL_ONE_DAY,
		self::INTERVAL_ONE_MONTH,
		'months'
			=> self::INTERVAL_ONE_MONTH,
		self::INTERVAL_ONE_YEAR,
		'years'
			=> self::INTERVAL_ONE_YEAR,
		default => throw new \Exception(
			sprintf('Unknown bill period: %s', $interval_unit),
		),
	};

	$date = new \DateTimeImmutable();
	$date = $date->setTimestamp($starting_timestamp);
	$is_start_date_leap_day =
		$date->format('m-d') === '02-29';

	$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;

	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();
	}

	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();
	}

	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',
			$interval_count,
			$interval_unit,
		),
	);
}

Testing dates

One other challenge with all this computation is how to make sure it works correctly. For the above function, I wrote a suite of tests to prove that it operates following the rules we set out. However, what about testing the code that uses this function to update billing records?

A frequent problem we had was that our integration tests for some billing operations would fail when they ran on certain days of the month (primarily the end of the month). When that happened, we only had 24 hours to debug and try to fix the tests before they stopped failing again. Of course, this is foolish; automated tests, even high-level tests, should not be subject to the whims of the wall clock. But PHP doesn’t have a built-in way to mock the current day or time.

So I had to invent one.

I came up with a class I called Store_Time to which has a method that returns the current timestamp (and some helpers for other common formats). I then replaced every instance of PHP’s time() I could find in our codebase with this function. This was not trivial because lots of PHP date functions and classes use the current time as a default, so code like new DateTime() or strtotime('+1 day') also needed to be replaced.

The class then has a mechanism to override the current timestamp which can be used by automated tests to test their flows on a specific date. For safety, the override only works if the class is not running in a production environment.

Here’s a slightly simplified version of how it looks:

class Store_Time {
	/**
	 * Set this to allow Store_Time to allow overrides.
	 *
	 * NOTE: You should probably not use this mechanism in real code. Use
	 * something specific to your environment.
	 */
	public static bool $is_testing_environment = false;

	private static string|null $time_string_override = null;

	/**
	 * Make sure this is only run in automated tests or on sandbox.
	 */
	private function can_safely_override_time_string(): bool {
		if (self::$is_testing_environment) {
			return true;
		}
		return false;
	}

	/**
	 * Return just the overridden time string, if any
	 */
	private function get_overridden_time_string(): string|null {
		if (!$this->can_safely_override_time_string()) {
			return null;
		}
		return self::$time_string_override;
	}

	/**
	 * Override the time returned by this class.
	 *
	 * Has no effect outside a test environment.
	 *
	 * The time string is something that can be parsed by the `DateTimeImmutable`
	 * constructor, typically in the format `Y-m-d H:i:s`.
	 */
	public static function set_override(
		string $time_string,
	): void {
		self::$time_string_override = $time_string;
	}

	public static function clear_override(): void {
		self::$time_string_override = null;
	}

	/**
	 * Return true if the time is being overridden.
	 */
	public function has_override(): bool {
		return (bool) $this->get_overridden_time_string();
	}

	/**
	 * Return the current or overridden time as a `DateTimeImmutable`.
	 */
	public function get_date_time(): \DateTimeImmutable {
		$today = $this->get_overridden_time_string() ?? 'now';
		return new \DateTimeImmutable($today);
	}

	/**
	 * Return the current or overridden time as a unix timestamp.
	 */
	public function get_timestamp(): int {
		return $this->get_date_time()->getTimestamp();
	}

	/**
	 * Return the current or overridden time formatted as per
	 * `DateTimeImmutable->format()`.
	 */
	public function format(string $format): string {
		return $this->get_date_time()->format($format);
	}
}

Date math is hard

As you can see, it’s difficult to manipulate dates at the best of times. It helps a lot to make sure you know what you want to happen, and then there are ways to make that work. If you’d like to see more about the code I used here, you can see these classes with full automated tests in a repo I’ve created to showcase them.

Photo by Claudio Schwarz on Unsplash


Comments

One response to “Thirty days hath September”

  1. Nice write up, Payton!

Leave a reply to Jess Boctor Cancel reply