Monthly Quota Reset and Billing Boundaries

A rate limit resets continuously; a quota resets on a boundary that finance cares about. Getting that boundary wrong produces support tickets that are also invoicing disputes, which is a worse category of bug — and it is the part of tiered access and quota enforcement where the correct answer comes from the billing system rather than from the limiter.

The problem in concrete numbers

A customer subscribes on 31 January with 100,000 calls included. Their quota should reset on the anniversary of that date — but February has no 31st. Reset on 28 February and they lose three days of their period; reset on 3 March and they get three extra. Multiply across thousands of customers and the aggregate error is real revenue.

Now add time zones. A customer in Auckland sees their month roll over at 13:00 on the last day, by your servers’ clock in UTC. If your dashboard shows “resets in 4 hours” and their finance team sees an invoice dated a day earlier, both are right and neither is explicable without knowing which clock each used.

Anniversary and calendar reset boundaries compared An anniversary period runs from the subscription date to the same day next month and must clamp to the last valid day in short months, while a calendar period resets for every customer on the first of the month and creates a synchronised usage spike. Two ways to define “a month” anniversary 31 Jan → 28 Feb → 31 Mar clamp to the last valid day matches the invoice exactly resets spread across the month calendar everyone resets on the 1st simple to explain first month is partial a usage spike every month start whichever you choose, the limiter must read the boundary from billing, never recompute it Displaying a reset instant in the right time zone A boundary shown in server time is meaningless to a customer in another zone, one shown in their zone without naming it is ambiguous, and one shown in their zone with the zone named matches what their invoice says. Same instant, three presentations server time means nothing locally mismatches the invoice support ticket local, unnamed closer but ambiguous shifts with daylight saving still confusing local, zone named matches the invoice unambiguous the only safe form store the instant in UTC and convert only for display

Decision matrix

Question Options Recommendation
Period type Anniversary / calendar Anniversary if you invoice per subscription, calendar if you invoice everyone monthly
Source of the boundary Billing system / limiter computes it Always the billing system: one truth, no drift
Time zone UTC / customer’s / account’s billing zone The account’s billing zone, displayed with the zone named
Short-month handling Clamp / skip / roll forward Clamp to the last valid day
Counter reset Delete key / new key per period New key per period identifier
Mid-cycle upgrade New allowance now / at next period New allowance now, prorated, with the old usage carried
Downgrade Immediate / at period end At period end, to avoid an instant over-quota state
Overage Hard stop / soft allow with billing Explicit product decision, surfaced in the response

The single most important row is the second. If the limiter computes “one month from the subscription date” independently, it will eventually disagree with the invoice — over a leap day, a plan change, a daylight-saving transition — and the customer will trust the invoice.

Step-by-step implementation

python
# quota.py — the period identifier comes from billing; the limiter only counts.
from datetime import datetime, timezone
from calendar import monthrange
from zoneinfo import ZoneInfo

def clamp_day(year: int, month: int, day: int) -> int:
    """31 Jan + 1 month = 28/29 Feb, not 3 March."""
    return min(day, monthrange(year, month)[1])

def next_anniversary(period_start: datetime, anchor_day: int, tz: str) -> datetime:
    """Only used to VERIFY billing's boundary, never to replace it."""
    local = period_start.astimezone(ZoneInfo(tz))
    year, month = (local.year + (local.month == 12), local.month % 12 + 1)
    return local.replace(day=clamp_day(year, month, anchor_day),
                         year=year, month=month).astimezone(timezone.utc)

def period_id(subscription) -> str:
    """Stable identifier for the current billing period, from billing's own fields."""
    start = subscription.current_period_start          # written by the billing system
    return f"{subscription.id}:{start.strftime('%Y%m%dT%H%M%SZ')}"

QUOTA_LUA = """
-- KEYS[1] = quota counter for this period. ARGV: limit, cost, ttl_seconds
local limit = tonumber(ARGV[1])
local cost  = tonumber(ARGV[2])
local ttl   = tonumber(ARGV[3])
local used  = tonumber(redis.call('GET', KEYS[1]) or '0')
if used + cost > limit then
  return { 0, used, limit - used }
end
local new = redis.call('INCRBY', KEYS[1], cost)
if new == cost then redis.call('EXPIRE', KEYS[1], ttl) end   -- first write sets the TTL
return { 1, new, limit - new }
"""

def check_quota(redis, subscription, cost=1):
    pid = period_id(subscription)
    key = f"quota:{{{subscription.account_id}}}:{pid}"
    # TTL outlives the period so a late request in the period still counts,
    # and the key disappears once the period is safely closed.
    ttl = int((subscription.current_period_end - datetime.now(timezone.utc)).total_seconds()) + 86_400
    allowed, used, remaining = redis.eval(QUOTA_LUA, 1, key,
                                          subscription.quota, cost, max(ttl, 60))
    return {
        "allowed": bool(allowed),
        "used": used,
        "remaining": max(0, remaining),
        "reset_at": subscription.current_period_end,   # the invoice's instant, not ours
    }

Keying by period identifier rather than resetting counters is what makes the whole scheme safe: nothing has to run at midnight, a late request lands in the period it belongs to, and last period’s usage stays readable for support and reconciliation until its TTL expires.

What happens to usage when a plan is upgraded mid-period On upgrade the billing system closes the current period and opens a new one, so the limiter switches to a new counter key with the higher allowance while the old period's usage remains readable for the invoice. Upgrade on day 12 of a 30-day period period A (pro, 50k) used 41,200 — kept for the invoice upgrade period B (enterprise, prorated 300k) new counter key, usage restarts at 0 the wrong alternative: raise the limit on the same key, and 41,200 counts against the new plan a new period identifier makes the switch atomic and the history auditable

Gotchas and edge cases

  • Recomputing the boundary in the limiter. Two systems computing “one month later” will disagree at leap days, plan changes, and daylight-saving transitions. Read it from billing.
  • Scheduled reset jobs. A cron that zeroes counters at midnight will miss a run, run twice, or run while requests are in flight. Key by period instead; nothing needs to run.
  • Displaying UTC to the customer. “Resets at 00:00 UTC” is meaningless to somebody in Auckland. Show their zone and name it.
  • Daylight-saving transitions. A period boundary at 02:30 local time happens twice or not at all on transition days. Store instants in UTC and convert for display only.
  • Losing the counter on a failover. For a quota that drives billing, an in-memory or unreplicated counter is a revenue bug. Persist it, and reconcile against your event log.
  • Downgrades applied immediately. Dropping a customer from 300,000 to 50,000 mid-period when they have already used 80,000 puts them instantly over quota. Apply downgrades at the period boundary.
  • Deleting old period counters immediately. Support and finance both need last period’s number. Let the TTL expire it a day or two after the period closes.

Verification and testing

python
# quota_boundary_test.py — the cases that actually break in production.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

def test_short_month_clamps():
    start = datetime(2026, 1, 31, 9, 0, tzinfo=timezone.utc)
    assert next_anniversary(start, 31, "UTC").day == 28          # 2026 is not a leap year

def test_leap_day_clamps():
    start = datetime(2028, 1, 31, 9, 0, tzinfo=timezone.utc)
    assert next_anniversary(start, 31, "UTC").day == 29          # 2028 is

def test_period_id_changes_on_upgrade(sub_factory):
    sub = sub_factory(quota=50_000)
    before = period_id(sub)
    sub.upgrade(plan="enterprise", quota=300_000)                # billing opens a new period
    assert period_id(sub) != before                              # new key, usage restarts

def test_usage_survives_a_late_request(redis, sub_factory):
    sub = sub_factory(quota=100)
    key = f"quota:{{{sub.account_id}}}:{period_id(sub)}"
    check_quota(redis, sub, cost=1)
    assert redis.ttl(key) > (sub.current_period_end - datetime.now(timezone.utc)).total_seconds()

def test_display_names_the_zone(client, sub_factory):
    sub = sub_factory(billing_tz="Pacific/Auckland")
    body = client.get("/v1/usage", account=sub.account_id).json()
    assert body["reset_at_local"].endswith("NZDT") or body["reset_at_local"].endswith("NZST")

Run the first two on every release. Month-boundary arithmetic is the kind of code that looks obviously right and is wrong for exactly two days a year, which is long enough for the error to reach an invoice before anybody notices.

Frequently Asked Questions

Should quotas reset on the calendar month or the subscription anniversary?

Match whatever your invoices use. Anniversary periods align usage with the amount charged and spread resets across the month; calendar periods are easier to explain and produce a usage spike on the first of each month. Mixing them — invoicing by anniversary while resetting by calendar — guarantees disputes.

What happens to usage when a customer upgrades mid-period?

The cleanest model is for billing to close the current period and open a new one at the moment of the upgrade, with a prorated allowance. The limiter then switches to a new counter key, so previous usage stays attached to the plan that was in effect and does not consume the new allowance.

Which time zone should the boundary use?

Store instants in UTC and derive the boundary from the account's billing time zone, then display it in that zone with the zone named. A customer comparing your dashboard's countdown against their invoice date needs both to refer to the same clock.

Should exceeding a quota return 429?

No — a spent quota is not a transient condition a retry can pass. Return 402 Payment Required or a 403 with a machine-readable quota_exceeded reason and the reset instant, so clients stop rather than entering a retry loop against a wall they cannot get past until the period rolls.