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.completed arrives first and carries the session, including your tenant key in metadata.
  • customer.subscription.created / .updated carries 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:

  1. retrieves the subscription from Stripe; stored state is never trusted for the item id or the current price;
  2. 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;
  3. otherwise sends Subscription.update with a stable idempotency key derived from the tenant, the reference and the mapping version;
  4. 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