We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
From the blog
Annual Plans with Monthly Usage Limits in Phoenix
By Liam Killingback ·
Annual Plans with Monthly Usage Limits in Phoenix
A customer pays you for a year up front. Your plan says “1,000 AI generations a month”. So when does their allowance reset?
Not on the 1st of every calendar month, because they subscribed on the 14th and their Stripe invoices say the 14th. Not once a year on their renewal date either, because then they get 12,000 generations on day one and can burn the lot in a week. What you actually want is a monthly allowance that resets on their anniversary day, inside a subscription that only bills once a year.
That is three clocks at once: Stripe’s yearly billing period, the customer’s monthly allowance window, and the calendar month most metering code assumes. This post shows how to get annual plans with monthly usage limits right in Phoenix with Aurora Meter: one small period source that slices the yearly subscription into monthly windows, the month arithmetic that trips everyone up (the 31st), the tests that pin it down, and the decisions you have to make about overage and upgrades on annual plans.
First decide: monthly allowance or annual pool?
Before writing code, pick which promise you are making. Both are legitimate and both are common.
| Monthly allowance | Annual pool | |
|---|---|---|
| What the customer gets | 1,000 per month, reset on their anniversary day | 12,000 for the year, used whenever |
| Burst protection | Strong: a bad week costs at most one month | Weak: one script can empty the year |
| Seasonal customers | Lose unused allowance each month | Can save it for their busy season |
| Your cost exposure | Predictable per month | Front-loaded, harder to forecast |
| Matches the monthly plan | Exactly, so switching monthly to annual changes only the price | No, the two plans behave differently |
Most SaaS products with real per-unit costs (AI, email sending, API calls you pay a vendor for) choose the monthly allowance. It keeps the annual plan identical to the monthly plan except for the price and the billing interval, which makes your pricing page, your support answers and your margins simpler.
If you choose the annual pool, you need almost nothing from this post: Aurora Meter Pro’s subscription-aligned periods already give you one counter per Stripe billing period, and for a yearly subscription that period is the year. Declare the yearly number as the limit and you are done.
The rest of this post is about the monthly allowance, which is the harder and more common one.
The plans
Declare the annual plan as its own plan id with the same limits as the monthly one. A separate id is what lets Stripe map a different price to it, and keeping the limits identical is what makes the two plans interchangeable.
defmodule MyApp.Plans do
use AuroraMeter.Plans
plan :free do
price 0
limit :ai_generations, 50, :hard
end
plan :pro do
price 2_000
limit :ai_generations, 1_000, :hard
feature :api_access, true
end
# Billed yearly in Stripe. `price` is the monthly equivalent shown in your UI.
plan :pro_annual do
price 1_600
limit :ai_generations, 1_000, :hard
feature :api_access, true
end
end
With Aurora Meter Pro, each plan maps to a Stripe price, so the annual plan points at a yearly price:
config :aurora_meter_pro,
stripe_prices: %{pro: "price_pro_monthly", pro_annual: "price_pro_yearly"}
So far nothing about periods. That comes next, and it is where the default behaviour bites.
Why neither default period works
Every Aurora Meter counter is a counter for a period: a half-open UTC interval [start, end) resolved by the configured :period_source. Two sources ship with it, and for an annual plan with a monthly allowance both give the wrong answer.
The core default is the calendar month. Usage resets at 00:00 UTC on the 1st. The allowance is monthly, which is right, but it resets on a different day from the customer’s billing date. A customer who subscribes on 28 October gets three days of allowance before their first reset. Nobody complains about getting a bonus, but your usage numbers and your invoices never line up, and “why did my usage reset on the 1st when I pay on the 28th?” is a support ticket you will answer forever.
Pro’s subscription source aligns to the Stripe period. AuroraMeter.Pro.Period returns the subscription’s current_period_start and current_period_end when the tenant has an entitled subscription. For a monthly plan that is exactly right. For an annual plan, the Stripe period is the whole year, so the counter runs for twelve months and a limit of 1,000 becomes 1,000 per year. That is the bug you do not notice until an annual customer writes in to ask why they are blocked in March.
What you want is in between: the subscription’s anchor, sliced into months.
A period source that slices the year into months
A period source is a module implementing the AuroraMeter.Period behaviour: current/2 takes the tenant and an instant and returns %{start:, end:, source:}. This one reads the tenant’s subscription window and returns the monthly slice of it that contains the instant.
defmodule MyApp.Billing.MonthlyAllowancePeriod do
@moduledoc """
Monthly usage windows anchored to the subscription start.
A monthly subscription gets its own billing period. An annual subscription
gets twelve monthly slices of its year, each starting on the anniversary day.
Anything without an entitled subscription window falls back to the calendar
month.
"""
@behaviour AuroraMeter.Period
alias AuroraMeter.Period.Calendar, as: CalendarMonth
alias AuroraMeter.Schema.Subscription
alias AuroraMeter.Subscriptions
@impl AuroraMeter.Period
def current(tenant, now) do
case Subscriptions.get(tenant) do
%Subscription{
current_period_start: %DateTime{} = window_start,
current_period_end: %DateTime{} = window_end
} = subscription ->
if Subscription.entitled?(subscription) and within?(window_start, window_end, now) do
slice(window_start, window_end, now)
else
CalendarMonth.current(tenant, now)
end
_none ->
CalendarMonth.current(tenant, now)
end
end
@impl AuroraMeter.Period
def containing(tenant, instant), do: current(tenant, instant)
@doc "The monthly slice of `[window_start, window_end)` that holds `now`."
def slice(window_start, window_end, now) do
guess = (now.year - window_start.year) * 12 + (now.month - window_start.month)
n =
if DateTime.compare(shift_months(window_start, guess), now) == :gt,
do: guess - 1,
else: guess
%{
start: shift_months(window_start, n),
end: earliest(shift_months(window_start, n + 1), window_end),
source: :monthly_allowance
}
end
# Always shift from the anchor, never from the previous slice, so a
# subscription that started on the 31st returns to the 31st after February.
defp shift_months(%DateTime{} = anchor, n) do
total = anchor.year * 12 + (anchor.month - 1) + n
year = div(total, 12)
month = rem(total, 12) + 1
day = min(anchor.day, :calendar.last_day_of_the_month(year, month))
%{anchor | year: year, month: month, day: day}
end
defp earliest(a, b), do: if(DateTime.compare(a, b) == :gt, do: b, else: a)
defp within?(start, finish, instant) do
DateTime.compare(start, instant) != :gt and DateTime.compare(instant, finish) == :lt
end
end
Then point Aurora Meter at it:
config :aurora_meter,
provider: AuroraMeter.Pro.Stripe,
period_source: MyApp.Billing.MonthlyAllowancePeriod
That is the whole feature. Every track/4, with_quota/4, quota/2 and <.usage_meter> now counts against the customer’s current monthly slice. A new slice starts a fresh counter automatically, because a counter is keyed by {tenant, feature, period_start}; there is no reset job to schedule and nothing to forget.
A few things in that module are deliberate.
It works for monthly subscribers too. A monthly subscription’s window is one month long, so the slice containing any instant is the whole window. You do not need two period sources or a branch on the plan.
It falls back to the calendar month, exactly like Pro’s own source, when the tenant has no subscription, the subscription is not entitled, or the stored window does not contain the instant (clock skew around a renewal, for example). Free-plan tenants therefore keep calendar-month quotas.
It is on the hot path, so it does no I/O. Aurora Meter calls the period source on every increment. AuroraMeter.Subscriptions.get/1 reads through the library’s subscription cache rather than the database, and everything else is integer arithmetic. The source is also pure: the same tenant and instant always give the same period, which the period contract requires.
containing/2 answers from the current window. Pro does not keep historical subscription windows, so for an instant older than the current year this falls back to the calendar month. That is the same limitation Pro’s own source documents, and it only matters for attributing very old events.
On the free core
The source reads current_period_start and current_period_end from Aurora Meter’s subscription row. Pro’s Stripe webhook sync keeps those fields current from customer.subscription.* events. If you run the free core with your own Stripe integration, either write those two fields yourself when your webhook handler sees a renewal, or read the anchor from your own billing table in current/2, keeping it cached in ETS so the hot path still never touches the database. The slicing logic does not change.
The month arithmetic: the 31st problem
The single most common bug in anniversary billing is chaining month additions. Start on 31 January, add a month and you get 28 February. Add a month to that and you get 28 March, and every later month is stuck on the 28th. The customer has silently lost three days of every month for the rest of the year.
shift_months/2 avoids that by always computing slice n from the original anchor, clamping only the day that does not exist:
| Slice | Start (anchor 31 Jan 2026, 10:00 UTC) |
|---|---|
| 0 | 31 Jan 2026 |
| 1 | 28 Feb 2026 |
| 2 | 31 Mar 2026 |
| 3 | 30 Apr 2026 |
| 4 | 31 May 2026 |
| 11 | 31 Dec 2026 |
| end | 31 Jan 2027 (the subscription’s renewal) |
This matches how Stripe itself treats billing anchors at the end of the month: an anchor on the 31st bills on the last day of shorter months and returns to the 31st when it can. Your usage resets and Stripe’s view of the customer’s month stay on the same day.
The time of day is kept too. If the subscription started at 10:00 UTC, every slice boundary is at 10:00 UTC, so an increment at 09:59 on the anniversary day still counts against the old month. That is the half-open interval doing its job: the boundary instant belongs to the new slice and to nothing else.
Testing it
Because slice/3 is a pure function of three DateTimes, the tests need no database, no Stripe and no clock freezing:
defmodule MyApp.Billing.MonthlyAllowancePeriodTest do
use ExUnit.Case, async: true
alias MyApp.Billing.MonthlyAllowancePeriod, as: Period
@start ~U[2026-01-31 10:00:00Z]
@finish ~U[2027-01-31 10:00:00Z]
test "the 31st clamps to the end of February and comes back in March" do
assert %{start: ~U[2026-01-31 10:00:00Z], end: ~U[2026-02-28 10:00:00Z]} =
Period.slice(@start, @finish, ~U[2026-02-15 00:00:00Z])
assert %{start: ~U[2026-02-28 10:00:00Z], end: ~U[2026-03-31 10:00:00Z]} =
Period.slice(@start, @finish, ~U[2026-03-01 00:00:00Z])
end
test "the boundary instant belongs to the next slice" do
assert %{start: ~U[2026-03-31 10:00:00Z]} =
Period.slice(@start, @finish, ~U[2026-03-31 10:00:00Z])
assert %{start: ~U[2026-02-28 10:00:00Z]} =
Period.slice(@start, @finish, ~U[2026-03-31 09:59:59Z])
end
test "the last slice ends with the subscription" do
assert %{start: ~U[2026-12-31 10:00:00Z], end: @finish} =
Period.slice(@start, @finish, ~U[2027-01-15 00:00:00Z])
end
test "twelve slices tile the year with no gaps and no overlaps" do
slices =
Stream.unfold(@start, fn t ->
if DateTime.compare(t, @finish) == :lt do
slice = Period.slice(@start, @finish, t)
{slice, slice.end}
end
end)
|> Enum.to_list()
assert length(slices) == 12
assert hd(slices).start == @start
assert List.last(slices).end == @finish
end
end
The last test is the important one. Walking from slice end to slice end and landing exactly on the renewal proves there is no instant that belongs to two months and no instant that belongs to none, which is what keeps one customer’s usage from being counted twice or not at all.
For an end-to-end check, Aurora Meter’s test helpers can freeze the clock (AuroraMeter.Test.with_clock/2 and travel/1), so you can track usage just before and after a slice boundary and assert that AuroraMeter.usage/2 starts again from zero. Our guide to testing usage-based billing in Elixir covers that pattern in depth.
Showing the reset date to customers
An annual customer will ask “when does my allowance reset?” more often than a monthly one, because their invoice date does not answer it. Put the answer next to the meter. AuroraMeter.period/1 returns the current slice through the same source:
def mount(_params, _session, socket) do
org = socket.assigns.current_org
if connected?(socket), do: AuroraMeter.LiveView.subscribe(org)
{:ok, assign(socket, period: AuroraMeter.period(org))}
end
<.usage_meter tenant={@current_org} feature={:ai_generations} />
<p class="text-sm text-zinc-500">
Your 1,000 monthly generations reset on <%= Calendar.strftime(@period.end, "%-d %B") %>.
Your plan renews yearly.
</p>
Saying both dates explicitly (“resets monthly on the 14th, renews yearly on 14 October”) removes most of the confusion before it reaches your inbox. Pair it with warnings at 80% and 100% of the monthly allowance, as in our post on quota alerts in Phoenix, and annual customers are never surprised by a block.
Upgrades, downgrades and the anchor
Annual plans change the anchor less often, but when it changes it matters.
Monthly to annual. When a customer switches from monthly to yearly billing in Stripe, the change of interval starts a new billing period at the switch, which means a new anchor. The next Subscriptions.get/1 after the webhook sync returns the new window, the first slice starts at the switch, and the customer gets a fresh monthly allowance at that moment. That is generous by up to one month’s allowance, and almost every product accepts it as the cost of a customer committing for a year. If you do not, read the old slice’s usage before the switch and carry it into the new slice with AuroraMeter.track(org, :ai_generations, used) once the webhook has landed.
Annual to monthly. Most products only allow this at renewal, because refunding part of a prepaid year is a policy question, not a code question. Aurora Meter can hold that change until the end of the term with a scheduled plan transition (AuroraMeter.Subscriptions.schedule_transition/3, which defaults its effective time to the end of the tenant’s period). Be aware that with this period source “the tenant’s period” is the current monthly slice, so pass effective_at: explicitly with the subscription’s current_period_end when you mean the end of the year.
Price changes. Annual customers are the ones most likely to expect their price to hold until renewal. Aurora Meter’s plan versions let you change the plan for new customers without repricing existing ones; our post on grandfathered pricing in Phoenix walks through it.
What about overage on an annual plan?
This post uses hard limits on purpose. Metered overage on an annual subscription is awkward: Stripe invoices a subscription’s usage-based prices on the subscription’s own cycle, so overage attached to a yearly subscription would be billed once, at the end of the year. That is a long time to extend credit, and a large surprise invoice for the customer. Check Stripe’s current support for mixed billing intervals in your account before designing around anything else.
Three patterns work well instead:
- Hard cap, upgrade to go further. The simplest. The annual plan blocks at 1,000 a month, and the upgrade path is a bigger annual plan.
- Prepaid credits for the extra. The plan allowance covers the month; anything beyond it draws from a credit balance the customer tops up. Aurora Meter’s credit ledger handles the holds and settlements, as in pay-as-you-go billing with prepaid credits.
- A separate monthly subscription for usage. The yearly subscription carries the flat price; a second, monthly subscription carries the metered price. It is the most complex option and only worth it if overage is a large share of your revenue.
Switching period source on a live app
Changing :period_source changes counter identity. Counters already written are keyed by the calendar month’s period_start; the new source produces a different period_start for the current slice, so each existing customer’s usage for the rest of this month starts again from zero under the new key. In practice that is one free partial month, which is usually fine, but it is a billing change, so do it on purpose: deploy it close to the 1st of a month to keep the gift small, and tell your support team why some meters dropped. The periods guide that ships with the package (docs/periods.md) describes the same cutover for any change of boundary.
If you report metered usage to Stripe for monthly plans, deploy at a quiet time and confirm the usage reporter has flushed the old calendar counters before the switch, so nothing from the old key is stranded.
Do it with Aurora Meter
The free, MIT-licensed core (on GitHub) gives you the ETS counters, the plan DSL, with_quota/4 and the AuroraMeter.Period behaviour this post plugs into. Aurora Meter Pro adds what makes annual plans hands-off: Stripe Checkout and webhook sync that keep each subscription’s window current, subscription-aligned periods, usage reporting to Stripe Billing Meters for your monthly plans, and quota alerts. Pro is licensed per app from $99/mo, with a 14-day free trial (one per customer). You can see what it looks like in the Pro preview.
If you are starting a metered API product from scratch, the Aurora API Starter ships a complete Phoenix SaaS on the core, so plans like these drop straight in.
Summary
Annual plans with monthly usage limits need three clocks to agree: Stripe bills yearly, the allowance resets monthly, and both should use the customer’s anniversary day rather than the 1st. In Phoenix with Aurora Meter that comes down to:
- Declare the annual plan as its own plan id with the same limits as the monthly one.
- Replace the period source with one that slices the subscription window into monthly windows from its start, always shifting from the anchor so the 31st survives February.
- Keep it pure and cache-backed, because it runs on every increment.
- Test the slicing as a pure function, including the boundary instant and a full year that tiles with no gaps.
- Show customers both dates, prefer hard caps or prepaid credits over yearly-invoiced overage, and treat any change of period source as a deliberate cutover.
Get those right and your annual customers get exactly what the pricing page promised, every month of the year.