Selling a subscription
The product. Inkwell charges $29 a month for the Writer plan. A customer clicks Upgrade, pays at Stripe, and comes back with the plan already applied. Later they cancel, and the plan goes away on its own.
This guide is that round trip.
1. Send them to Stripe
defmodule InkwellWeb.BillingController do
use InkwellWeb, :controller
def upgrade(conn, %{"plan" => plan}) do
org = conn.assigns.current_org
{:ok, url} =
AuroraMeter.Billing.checkout(org, String.to_existing_atom(plan),
success_url: url(~p"/billing?upgraded=1"),
cancel_url: url(~p"/billing")
)
redirect(conn, external: url)
end
end
checkout/3 looks the plan up in stripe_prices, attaches any
stripe_metered_prices for it so overage can be invoiced, stamps your tenant
key into the session metadata, and hands back a URL.
String.to_existing_atom/1, not String.to_atom/1. The parameter comes
from the browser and to_atom on user input leaks the atom table.
2. What comes back
Two different events can tell you the customer paid, and Pro handles both because neither is reliable on its own:
-
checkout.session.completedarrives first and carries the session, including your tenant key in metadata. -
customer.subscription.created/.updatedcarries the real subscription, with its status and period bounds.
They arrive independently, each with its own retry schedule, so the order is not guaranteed. Pro takes whichever arrives and fills in what it knows, refusing to let an older payload overwrite a newer one.
You do not write any of this. Mounting the plug is the whole integration.
3. The plan is live immediately
AuroraMeter.plan(org).id # => :writer
AuroraMeter.check(org, :generations) # => :ok
There is no sync job to wait for and no cache to bust. The subscription row is written by the webhook and read on every check through a short-lived ETS cache that the write itself evicts, across every node.
4. Status is what grants the plan
A subscription grants its plan only while its status is one of:
active trialing past_due
Anything else (canceled, unpaid, incomplete, incomplete_expired)
falls back to your :default_plan:
config :aurora_meter, default_plan: :free
So a cancellation revokes access by itself. There is no downgrade job to write, and no window in which a cancelled customer keeps paid features because your nightly sweep has not run.
4a. Periods align to the window, but only when the window contains “now”
With config :aurora_meter, period_source: AuroraMeter.Pro.Period, usage
buckets to the subscription’s own current_period_start and
current_period_end rather than to the calendar month, so a customer who
subscribes on the 17th gets a period that starts on the 17th.
That window is used only when the subscription is entitled and the instant
being resolved falls inside it: start <= instant < end, the half-open interval
the core requires (see the core’s periods.md). Both bounds are checked. Before
1.0.0 only the upper one was, which meant that when the stored
current_period_start was ahead of this node’s clock, the window was returned
for an instant it does not contain.
The consequence, which is the one behaviour change here. Around a renewal,
the provider can hand you a new window whose start is a second or two ahead of
your node’s clock. In that gap, usage buckets to the calendar month instead
of the subscription window, and the reporter sees a calendar-period row for it.
The gap is bounded by the skew, the difference is visible to reconciliation, and
the alternative is worse: AuroraMeter.Period.current!/2 validates every read on
the metering path, so returning a window that does not contain the instant would
raise there, and metering would stop rather than mis-bucket a second of usage.
AuroraMeter.Pro.Period.containing/2 answers the same question for a past
instant. When the current window cannot hold it, it returns that instant’s
calendar month tagged source: :calendar_fallback rather than :calendar, so a
later reader can tell “the subscription window answered” from “we used a
calendar month because we had nothing better”. current/2 never produces that
atom, so anything matching on :calendar is unaffected. Pro does not store
historical subscription windows, so containing/2 reads the current one: right
for an instant inside it, explicitly a fallback for anything older.
past_due is deliberately still entitled. A failed renewal is usually an
expired card, and locking someone out of the product while Stripe retries their
payment is how you turn a billing hiccup into a cancellation. Stripe will move
them to unpaid or canceled when it gives up, and that is when access ends.
5. The customer portal
Let Stripe handle card changes, invoices and cancellation:
def portal(conn, _params) do
case AuroraMeter.Billing.portal_url(conn.assigns.current_org,
return_url: url(~p"/billing")) do
{:ok, url} -> redirect(conn, external: url)
{:error, :no_customer} -> redirect(conn, to: ~p"/billing/upgrade")
end
end
{:error, :no_customer} means this tenant has never paid you, so there is
nothing to manage. Send them to your pricing page rather than showing an error.
To pin a particular portal configuration (which features the portal offers),
set stripe_portal_configuration.
6. Current state and event ordering
Stripe retries events independently, so an old active snapshot can arrive after
cancellation or a plan change. Subscription and subscription-mode Checkout
webhooks now call AuroraMeter.Billing.sync_subscription/1. The configured
Stripe provider retrieves the current subscription while holding the tenant
advisory lock, checks its tenant metadata, and persists that current state.
An API error requests redelivery; the handler does not fall back to the old
snapshot. The credential needs subscription read access.
Checkout therefore obtains real subscription period bounds rather than inventing an active row without dates. Payment-mode Checkout is handled by prepaid credits, and other modes are ignored.
AuroraMeter.Pro.Subscriptions.sync/1 remains a low-level current-snapshot
mapper with no network calls. Use it for fixtures or already-retrieved current
objects. Feed webhook signals through the provider facade when current-state
refresh is required.
There is one local subscription row per tenant. A terminal event for an older subscription cannot replace a different current subscription. This model does not arbitrate multiple simultaneously active Stripe subscriptions: use the portal for changes and prevent overlapping purchases in the host checkout flow. Settle metered usage before changing customer, plan or period anchors; see Usage reporting.
7. Testing it
test "a cancellation revokes the plan" do
org = org_fixture()
key = MyApp.tenant_key(org)
{:ok, _} = AuroraMeter.Pro.Subscriptions.sync(%{
"id" => "sub_1",
"status" => "active",
"metadata" => %{"tenant_key" => key},
"items" => %{"data" => [%{"price" => %{"id" => "price_flat_writer"}}]},
"current_period_start" => 1_772_000_000,
"current_period_end" => 1_774_678_400
})
assert AuroraMeter.plan(org).id == :writer
{:ok, _} = AuroraMeter.Pro.Subscriptions.sync(%{
"id" => "sub_1",
"status" => "canceled",
"metadata" => %{"tenant_key" => key},
"items" => %{"data" => [%{"price" => %{"id" => "price_flat_writer"}}]}
})
assert AuroraMeter.plan(org).id == :free
end
For event-ordering tests, keep the fake provider’s current subscription
cancelled, then deliver an old active event through
AuroraMeter.Pro.Webhook.handle_event/1. Assert that the refreshed plan remains
free. Do not replay the old object directly through Subscriptions.sync/1:
that low-level function treats its input as the authoritative current snapshot
and deliberately does not retrieve Stripe state.
8. Selling a second version of a plan
A plan is {plan_id, version} from 1.0 onward, and the price mapping is keyed
the same way. A bare plan id means version "1", so every mapping written
before this keeps working unchanged.
config :aurora_meter_pro,
stripe_prices: %{
{:writer, "1"} => "price_1AAA", # the customers who are on it stay on it
{:writer, "2"} => "price_1BBB", # everyone who signs up from now on
:scale => "price_1CCC" # a bare key means {:scale, "1"}
},
stripe_metered_prices: %{
{:scale, "1"} => ["price_1DDD"]
}
A new commercial version gets a NEW Stripe price. Never re-point an existing one: every customer on it would move to the new rate with no transition, no audit row and nothing they agreed to. Create the price in the Stripe dashboard, give it a lookup key, and add a pair to the mapping.
The recommended lookup keys, because Stripe can resolve a price by lookup key and that is how you recreate a mapping without copying ids by hand:
<plan_id>_v<version>_<interval> writer_v2_monthly, writer_v2_yearly
<plan_id>_v<version>_<feature>_metered scale_v2_ai_generations_metered
A price id belongs to exactly one {plan_id, version}, forever. Aurora
Meter Pro refuses to boot if two versions claim the same one, and says which
price and which pairs: resolving a subscription’s plan from its item prices
cannot be deterministic while two share one, and a customer would be attributed
to whichever the map happened to enumerate first.
stripe_meters is keyed by feature alone, and deliberately not by version.
A meter is a measurement channel, not a price. Two versions may measure one
feature and bill it at different rates through two different metered prices, and
they share the meter. See Usage reporting.
Sell a specific version at checkout with :version:
AuroraMeter.Billing.checkout(org, :writer, version: "2", success_url: ..., cancel_url: ...)
Both the plan id and the version travel in the session and subscription metadata, so a brand-new subscription whose price is not yet in the mapping is still attributable to a contract.
9. Moving a customer between versions
A plan change that needs no provider is free core functionality: schedule it
with AuroraMeter.Subscriptions.schedule_transition/3. A change that has to
move a Stripe price is this:
{:ok, transition} =
AuroraMeter.Pro.Transitions.schedule("org_1", {:writer, "2"}, ref: "upgrade-8412")
It writes the transition with confirm: :provider, which keeps it pending and
visible until Stripe’s own state shows the new price, and it resolves and
stores the target price id on the row now, so a mapping edited before the
change is sent cannot alter what is sent.
AuroraMeter.Pro.Transitions.Applier then drives it. Schedule it in your
crontab next to the other workers:
{"*/5 * * * *", AuroraMeter.Pro.Transitions.Applier}
“At the effective time” really means at the first run at or after the effective time, with the same lag core documents for its own applier: on a five minute schedule a change due at 00:00 is sent somewhere in 00:00 to 00:05. If that matters for a particular change, run the worker once by hand:
AuroraMeter.Pro.Transitions.Applier.run(tenant: "org_1")
What the applier does, and the order matters:
- retrieves the subscription from Stripe; stored state is never trusted for the item id or the current price;
- if Stripe already shows the target, confirms and sends nothing. This is why a crash between the update and the local commit converges: the next run observes rather than re-decides;
-
otherwise sends
Subscription.updatewith a stable idempotency key derived from the tenant, the reference and the mapping version; - retrieves again and confirms only if Stripe’s own state shows the new price. A change Stripe scheduled for the period end leaves the transition pending with the provider’s reference recorded, and core applies it locally at that boundary.
A refusal from Stripe leaves the transition pending and opens a
plan_change_rejected reconciliation item. It is never applied locally,
because that would entitle a customer Stripe is not billing. A human decides.
Proration
Aurora Meter computes no proration of any kind. proration_behavior is passed
to Stripe untouched and defaults to :none, which is what “plan changes take
effect at the next period” means.
Measured against a real Stripe test account, on a $12 to $34 monthly upgrade halfway through a period:
| Setting | What Stripe did |
|---|---|
:none (the default) |
the item’s price moved immediately, zero proration lines, and the next invoice is simply $34 |
:create_prorations |
two lines on the upcoming invoice: Unused time at -$12 and Remaining time at +$34, and an upcoming total of $56 |
With :create_prorations, the lines accumulate: upgrading and then
downgrading inside one period leaves four of them. The net is right and the
invoice is not obviously readable, which is worth knowing before you turn it on.
Cancellation wins
If the customer has asked to cancel at the period end, a transition scheduled
at or after that instant is cancelled with
detail.reason = "cancel_at_period_end": a tenant who will not be a customer
then has no contract to move to. One scheduled before it is left pending,
because they are still paying until then.
Where next
- Usage reporting if the plan bills overage
- Top-ups if you also sell prepaid credit
- Operations for the dashboard and alerts