Take the money: Stripe subscriptions

The round trip. A customer clicks Upgrade, pays at Stripe, and comes back with the plan already live. Later they cancel, and the plan goes away on its own.

Uses Pro 16 min Intermediate

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.

  1. A Product per plan, for example Writer and Studio.
  2. A recurring Price on each. This is the flat monthly fee. Copy the price id, which starts price_.
  3. 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.
  4. 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, never String.to_atom/1. That parameter comes from a browser, and to_atom on 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.completed arrives first and carries the session, including your customer key in its metadata.
  • customer.subscription.created and .updated carry 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_due staying 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 to unpaid or canceled when 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 4242 and watched AuroraMeter.plan(org).id change.
  • 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_plan is set and it is your free tier.

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.