pythondjangodatetimetimezonebilling

How to get a billing cycle period between the 26th of the previous month and the 25th of the current month using Python (timezone-aware)?


The Problem

I'm building a billing system in Django, and I need to calculate the billing period for each invoice.

Our business rule is simple:

For example, if the current date is 2025-07-23, the result should be:

start = datetime(2025, 6, 26, 0, 0, 0)
end   = datetime(2025, 7, 25, 23, 59, 59)

We're using Django, so the dates must be timezone-aware (UTC preferred), as Django stores all datetime fields in UTC.

The problem is: when I run my current code (below), the values saved in the database are shifted, like 2025-06-26T03:00:00Z instead of 2025-06-26T00:00:00Z.

What We Tried

We tried the following function:

from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta

def get_invoice_period(reference_date: datetime = None) -> tuple[datetime, datetime]:
    if reference_date is None:
        reference_date = datetime.now()

    end = (reference_date - timedelta(days=1)).replace(hour=23, minute=59, second=59, microsecond=0)
    start = (reference_date - relativedelta(months=1)).replace(day=26, hour=0, minute=0, second=0, microsecond=0)

    return start, end

But this causes timezone problems, and datetime.now() is not timezone-aware in Django. So when we save these values to the database, Django converts them to UTC, shifting the time (e.g., +3 hours).

We looked at:

But none of them solves the business logic and works well with Django timezone-aware datetimes.

What We Expected

We expect a function that:

Edit - Solution

from django.utils import timezone
from dateutil.relativedelta import relativedelta

def get_invoice_period(reference_date=None):
    if reference_date is None:
        reference_date = timezone.localtime()

    start = reference_date - relativedelta(months=1)
    start = start.replace(day=26, hour=0, minute=0, second=0, microsecond=0)

    end = reference_date.replace(day=25, hour=23, minute=59, second=59, microsecond=0)

    return start, end

Solution

  • Not familiar with Django, but don't know why you couldn't use time-zone aware times. Then use datetime.datetime.replace to adjust your start/end of the billing period:

    import datetime as dt
    import zoneinfo as zi
    
    def billing_period(date):
        # To handle January, move to the first day
        # of the current month, then back up a day to
        # compute the previous month.
        prev_month = date.replace(day=1) - dt.timedelta(days=1)
        # now adjust to midnight of day 26.
        start = prev_month.replace(day=26, hour=0, minute=0, second=0, microsecond=0)
        # move current date to the last microsecond at end of day 25.
        end = date.replace(day=25, hour=23, minute=59, second=59, microsecond=999999)
        return start, end
    
    timezone = zi.ZoneInfo('US/Pacific')
    dates = [dt.datetime(2025, 7, 23, tzinfo=timezone),  # OP example
             dt.datetime(2025, 3, 9, 12, 30, 15, 500, tzinfo=timezone),  # start of DST
             dt.datetime(2025, 1, 9, 12, 30, 15, 500, tzinfo=timezone)]  # test January
    
    for date in dates:
        start, end = billing_period(date)
        print(date, start, end, sep='\n', end='\n\n')
    

    Output with comments:

    2025-07-23 00:00:00-07:00
    2025-06-26 00:00:00-07:00
    2025-07-25 23:59:59.999999-07:00
    
    2025-03-09 12:30:15.000500-07:00
    2025-02-26 00:00:00-08:00         # Previous day is PST
    2025-03-25 23:59:59.999999-07:00  # PDT
    
    2025-01-09 12:30:15.000500-08:00
    2024-12-26 00:00:00-08:00         # December of previous year
    2025-01-25 23:59:59.999999-08:00