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, and there is less code in it than you expect.
#The one idea to hold on to
Stripe is the payment rail. Your database is the source of truth.
Money arrives as a webhook, and every webhook can arrive twice, late, out of order, or never. So every write Pro makes is keyed on something Stripe will repeat, and a redelivery finds the work already done and changes nothing.
That idea is why the code below looks more careful than it “needs” to.
#1. Install
def deps do
[
{:aurora_meter, "~> 0.4"},
{:aurora_meter_pro, "~> 0.3", organization: "phxtemplates"}
]
end
mix aurora_meter.gen.migration -r MyApp.Repo # core, if you have not already
mix aurora_meter_pro.gen.migration -r MyApp.Repo # pro
mix ecto.migrate
On an existing install, pass --from N to generate a migration that applies
only the new versions.
Pro needs Elixir 1.15 with OTP 26 or newer. The Stripe client’s HTTP dependency does not compile on OTP 25.
#2. What to create in Stripe
Pro does not create your products for you, and the names have to line up with your config. Doing this in the wrong order is the most common way a first install misbehaves, so here is the whole list. Do it in test mode.
- A Product per plan, for example Writer and Studio.
-
A recurring Price on each. This is the flat monthly fee. Copy the price
id, which starts
price_. - If the plan bills overage: a Billing Meter, and a second, metered Price attached to it. Give that price a graduated tier, with the included allowance at $0 and everything above at your unit price.
-
A webhook endpoint pointing at your app, subscribed to the events in
section 4, and its signing secret, which starts
whsec_.
#3. Configure
config :aurora_meter,
provider: AuroraMeter.Pro.Stripe,
period_source: AuroraMeter.Pro.Period,
default_plan: :free
config :aurora_meter_pro,
stripe_prices: %{writer: "price_flat_writer", studio: "price_flat_studio"},
stripe_metered_prices: %{writer: ["price_writer_overage"]},
stripe_meters: %{generations: "generations"},
webhook_secret: {:system, "STRIPE_WEBHOOK_SECRET"}
period_source: AuroraMeter.Pro.Period is worth a moment. Without it a period
is a calendar month, so a customer who subscribes on the 20th gets a fresh
allowance eleven days later and feels robbed. With it, the period is their
billing cycle, and the allowance resets when they are charged.
Every secret may be given as {:system, "VAR"} and is read at runtime, so a
release does not bake it into the build.
#4. Mount the webhook
# endpoint.ex, BEFORE plug Plug.Parsers
plug AuroraMeter.Pro.Webhook
Before
Plug.Parsers, not after. Stripe signs the raw request body. Once a parser has read and re-encoded it, the bytes are different and every signature check fails. If that is awkward in your endpoint, forward a single path to a pipeline that keeps the raw body.
Subscribe the endpoint to these:
| Event | Why |
|---|---|
customer.subscription.created .updated .deleted |
keep the plan in step |
checkout.session.completed |
a subscription or a top up finished |
invoice.paid |
a new billing period began |
If you also sell prepaid credit, Let customers top up lists the payment and dispute events you need on top of these.
#Rotating the secret
webhook_secret accepts a list. Put the new secret alongside the old one,
deploy, switch Stripe over, then remove the old one. Events signed with either
are accepted in between, so a rotation does not drop anything on the floor.
#5. 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 customer
key into the session metadata, and hands back a URL.
String.to_existing_atom/1, neverString.to_atom/1. That parameter comes from a browser, andto_atomon anything a stranger can type will fill the atom table until the VM falls over.
#6. 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 customer key in its metadata. -
customer.subscription.createdand.updatedcarry 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 refuses to let an older payload overwrite a newer one: the handler retrieves the current subscription from Stripe while holding a lock on that customer, and stores that. An API error asks Stripe to redeliver rather than falling back to the stale snapshot.
You do not write any of this. Mounting the plug is the whole integration.
#7. 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 in-memory cache that the write itself clears, on every node.
#8. Status is what grants the plan
A subscription grants its plan only while its status is one of:
active trialing past_due
Anything else, including canceled, unpaid, incomplete and
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.
past_duestaying entitled is deliberate. A failed renewal is usually an expired card, and locking somebody out of the product while Stripe retries their payment is how you turn a billing hiccup into a cancellation. Stripe moves them tounpaidorcanceledwhen it gives up, and that is when access ends.
#9. 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"/pricing")
end
end
{:error, :no_customer} means this customer 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 decides what the portal offers,
set stripe_portal_configuration.
#10. One subscription per customer
There is one local subscription row per customer. A terminal event for an older subscription cannot replace a different current one.
This model does not arbitrate two Stripe subscriptions active at once. Use the portal for plan changes, and stop overlapping purchases in your own checkout flow before they start. If you do change a customer’s plan, meter mapping or billing anchor, settle their metered usage first; Get overage onto the invoice explains why.
#11. 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
Subscriptions.sync/1 is the low level mapper: it treats its input as the
authoritative current snapshot and makes no network calls, which makes it ideal
for fixtures and wrong for testing event ordering.
For an ordering test, keep the fake provider’s current subscription cancelled,
then deliver an old active event through
AuroraMeter.Pro.Webhook.handle_event/1 and assert the plan stays free. That is
the test that proves a late redelivery cannot resurrect a cancelled plan.
And keep the network out of your suite entirely:
# config/test.exs
config :aurora_meter_pro,
credits_stripe_client: AuroraMeter.Pro.Credits.StripeClient.Fake,
audit_log_writer: :sync
#Before you go live
-
You have run a real checkout in test mode with the card
4242 4242 4242 4242and watchedAuroraMeter.plan(org).idchange. - You have cancelled that subscription in the Stripe dashboard and watched the plan fall back to your default.
-
The webhook plug is before
Plug.Parsers, and the Stripe dashboard shows recent deliveries succeeding. -
default_planis set and it is your free tier.
#What to read next
- Get overage onto the invoice if a plan bills for going over.
- Let customers top up if you also sell prepaid credit.
- Ship it: the production checklist for the alarms worth having before the first real card.
Keep going
The core is free. Pro is the Stripe half.
Everything the free MIT package does is yours with no account and no expiry. Aurora Meter Pro adds Stripe checkout, metered usage reporting, prepaid top-ups, dashboards and alerts, and every plan starts with a free trial.