Write your plans down, then enforce them

Three tiers in one small module, switches that turn features off, seat counts, and the one function that makes a hard limit hold when two customers click at the same moment.

Free core 15 min Beginner

A plan is a list of promises. This guide is about writing those promises in one place, and then making sure the running product actually keeps them, including on the day two customers click the same button in the same millisecond.

The example product is Bramble, a project tool sold to teams. Three tiers. Some things are switched off on the cheap tier, some things are capped, and every tier allows a certain number of people. Bramble charges a flat monthly price, so no money changes hands inside Aurora Meter here. The only job is deciding who may do what.

#1. Write the plans down

defmodule Bramble.Plans do
  use AuroraMeter.Plans

  plan :free do
    price 0
    feature :seats, 3
    feature :pdf_export, false
    feature :audit_log, false
    limit :projects, 2, :hard
    limit :file_uploads, 100, :hard
  end

  plan :team do
    price 4_900
    feature :seats, 20
    feature :pdf_export, true
    feature :audit_log, false
    limit :projects, 50, :hard
    limit :file_uploads, 10_000, :hard
  end

  plan :business do
    price 19_900
    feature :seats, 200
    feature :pdf_export, true
    feature :audit_log, true
    limit :projects, 1_000, :hard
    limit :file_uploads, 250_000, :hard
  end
end

Three things to notice before we go on.

price is in cents. Not dollars, and not the micro-dollars the credit ledger uses. It is a label for your pricing page; Aurora Meter never charges it.

This module is checked when it compiles. A duplicate feature, a negative limit or a misspelled mode is a compile error. You find out while you are typing, not at 3am.

:seats is a feature, not a limit. That is deliberate, and it is the part people get wrong, so it gets the next section to itself.

#2. Seats are a number, not a counter

A limit counts events that happened. Seats are not events. Somebody joins, somebody leaves, and the number goes up and down. A counter only ever goes up until the period ends.

So :seats is a plan value you read, and you compare it against your own count of rows:

defmodule Bramble.Members do
  import Ecto.Query

  def can_invite?(org) do
    allowed = AuroraMeter.feature_value(org, :seats, 1)
    used = Repo.aggregate(from(m in Member, where: m.org_id == ^org.id), :count)
    used < allowed
  end
end

The third argument to feature_value/3 is the answer used when the plan says nothing at all about that feature. Choose it on purpose: 1 is a safe floor, 0 locks everybody out, 999 gives the shop away.

The rule of thumb. If removing one makes the number go down, it is a feature value. If it can only go up until the month ends, it is a limit or a metered feature.

#3. Switches: on or off

AuroraMeter.check(org, :pdf_export)
# :ok                       on :team and :business
# {:error, :not_entitled}   on :free

In a controller, the shape you almost always want:

defmodule BrambleWeb.ExportController do
  use BrambleWeb, :controller

  def pdf(conn, %{"project_id" => id}) do
    case AuroraMeter.check(conn.assigns.current_org, :pdf_export) do
      :ok ->
        render(conn, :pdf, pdf: Bramble.Exports.render_pdf(id))

      {:error, :not_entitled} ->
        conn
        |> put_status(:payment_required)
        |> json(%{error: "PDF export is available on Team and Business."})
    end
  end
end

In a template, a boolean reads better than a tuple:

<.link :if={AuroraMeter.allowed?(@org, :pdf_export)} href={~p"/projects/#{@project}/pdf"}>
  Download PDF
</.link>

#Two questions that look the same and are not

AuroraMeter.entitled?(org, :file_uploads)   # does the plan grant this at all?
AuroraMeter.allowed?(org, :file_uploads)    # and is there room right now?

A team on Free that has used all 100 uploads is entitled (their plan gives them uploads) but not allowed (they are out of them).

Use entitled?/2 to decide whether the feature appears at all. Use allowed?/2 to decide whether this particular click works. Swap them and you show an upgrade prompt for something the customer already pays for, which is the fastest way to make a paying customer feel cheated.

#4. Hard caps, and the bug everybody writes

AuroraMeter.check(org, :projects)
# :ok until 2 projects on :free, then {:error, :limit_exceeded}

Here is the trap. This code is wrong:

# WRONG. Two requests can both pass.
with :ok <- AuroraMeter.check(org, :projects) do
  {:ok, project} = Bramble.Projects.create(org, attrs)
  AuroraMeter.track(org, :projects)
  {:ok, project}
end

Two people click “New project” at the same moment, on a free plan that has one project. Both read 1 of 2. Both pass. Both create. The team now has three projects on a plan that allows two, and nothing will ever notice.

The whole class of bug disappears with one function, which reserves the count and the permission together:

defmodule Bramble.Projects do
  def create(org, attrs) do
    AuroraMeter.with_quota(org, :projects, fn ->
      %Project{} |> Project.changeset(attrs) |> Repo.insert!()
    end)
  end
end
Bramble.Projects.create(org, %{name: "Rebrand"})
# {:ok, %Project{}}
# {:error, :limit_exceeded}

Under any amount of concurrency, a cap of two admits exactly two. It increments first, compares, and rolls back if that put the customer over.

Three things to hold on to:

The reservation is the usage. with_quota/4 counts for you. Do not also call track/3, or every project counts twice.

A crash gives the slot back. If the function raises, or the process exits (a database checkout timeout is the common one), the reservation is released before the error travels on. A failed insert does not burn a slot.

More than one at a time is all or nothing:

AuroraMeter.with_quota(org, :file_uploads, length(files), fn ->
  Enum.map(files, &store!/1)
end)

Twelve files against ten remaining refuses all twelve, rather than storing ten and failing.

#5. Deleting things does not give the count back

A project is deleted. Does the count go down?

No, and that is on purpose. The counter measures projects created this period, not projects that exist. If deleting gave a slot back, a free team could create, delete, create, delete, for ever, each one a real row in your database for as long as they cared to keep it.

If what you actually want to cap is how many exist at once, that is the seats pattern from section 2: a plan value compared against a live COUNT(*).

Decide which of the two you mean before you write the plan. Both are legitimate. They are different products.

#6. Changing plan

AuroraMeter.subscribe(org, :team)

That is the whole upgrade, as far as metering goes. It takes effect immediately, because caps are read from the plan on every check, so a team blocked at two projects can create the third the instant they upgrade.

Usage is not reset by a plan change. A team that used 40 uploads on Free still has 40 used on Team. They now have 10,000 to play with, so it makes no practical difference, and resetting would let anybody refill by switching plan and switching back.

#When a subscription lapses

With the Pro package, subscriptions are synced from Stripe and carry a status. A plan is granted only while that status is active, trialing or past_due. Anything else, including canceled, unpaid and incomplete, falls back to the plan you configured as the default:

config :aurora_meter, default_plan: :free

So a cancellation revokes access on its own. You do not write a downgrade job, and there is no window where a cancelled customer keeps Business features because your nightly sweep has not run yet.

#7. An upgrade prompt that knows what to say

AuroraMeter.quota(org, :projects)
# %{feature: :projects, kind: :hard, used: 2, limit: 2, included: 2,
#   remaining: 0, overage: 0, percent: 100, enabled: true, unit_price: nil,
#   period: %{start: ~U[...], end: ~U[...], source: :calendar}}

One call, and now the prompt can be specific:

<div :if={@quota.kind == :hard and @quota.remaining == 0} class="upgrade">
  <p>
    You have used all <%= @quota.limit %> projects on Free.
    They reset on <%= Calendar.strftime(@quota.period.end, "%e %B") %>.
  </p>
  <.link href={~p"/billing/upgrade"}>Team gives you 50</.link>
</div>

That is three facts a customer can act on, instead of the word “Upgrade!” next to a red bar.

When that really is all you need:

AuroraMeter.remaining(org, :projects)      # => 0
AuroraMeter.remaining(org, :ai_summaries)  # => :unlimited (metered or counter)

#8. Features you have not declared

AuroraMeter.check(org, :some_new_thing)   # => :ok

An undeclared feature is permissive. A half-finished feature does not lock your customers out of the product, and in development it logs a warning so you notice before it ships.

If you would rather a typo be loud, declare every feature on every plan, with feature :some_new_thing, false on the tiers that should not have it. Then the warning is not your only defence.

#9. Testing the rules

The rules are the part of your product a bug is most expensive in, and they are cheap to test.

defmodule Bramble.ProjectsTest do
  use Bramble.DataCase, async: false

  setup do
    AuroraMeter.Test.reset!()
    :ok
  end

  test "free stops at two projects" do
    org = org_fixture()
    AuroraMeter.subscribe(org, :free)

    assert {:ok, _} = Bramble.Projects.create(org, %{name: "One"})
    assert {:ok, _} = Bramble.Projects.create(org, %{name: "Two"})
    assert {:error, :limit_exceeded} = Bramble.Projects.create(org, %{name: "Three"})
  end

  test "upgrading unblocks immediately" do
    org = org_fixture()
    AuroraMeter.subscribe(org, :free)
    AuroraMeter.track(org, :projects, 2)

    assert {:error, :limit_exceeded} = Bramble.Projects.create(org, %{name: "Three"})

    AuroraMeter.subscribe(org, :team)

    assert {:ok, _} = Bramble.Projects.create(org, %{name: "Three"})
  end

  test "the cap holds when everybody clicks at once" do
    org = org_fixture()
    AuroraMeter.subscribe(org, :free)

    results =
      1..20
      |> Task.async_stream(fn i -> Bramble.Projects.create(org, %{name: "P#{i}"}) end,
        max_concurrency: 20
      )
      |> Enum.map(fn {:ok, result} -> result end)

    assert Enum.count(results, &match?({:ok, _}, &1)) == 2
  end
end

That last test is the one worth having. It is the only one that fails if somebody “simplifies” with_quota/4 back into a check followed by a track.

#The whole thing, end to end

# once, at signup
AuroraMeter.subscribe(org, :free)

# should the button exist?
AuroraMeter.entitled?(org, :pdf_export)   # => false on :free

# can this person be invited?
Bramble.Members.can_invite?(org)          # 3 seats against COUNT(*)

# create a project, safely, under any concurrency
Bramble.Projects.create(org, attrs)       # {:ok, _} | {:error, :limit_exceeded}

# what should the dashboard say?
AuroraMeter.quota(org, :projects)         # %{used: 2, limit: 2, percent: 100, ...}

# they paid
AuroraMeter.subscribe(org, :team)
Bramble.Projects.create(org, attrs)       # {:ok, %Project{}}

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.