From the blog

How to Refund Usage-Based Billing in Elixir: Correct Metered Usage Without Rewriting History

By Liam Killingback ·

How to Refund Usage-Based Billing in Elixir: Correct Metered Usage Without Rewriting History

Sooner or later a customer writes in: “Your API timed out and still charged me for 3,000 tokens,” or “We ran a load test against production by mistake, can you take it off the bill?” With a flat subscription the answer is simple, you refund the charge. With usage-based billing it is not, because the charge does not exist yet. What exists is a pile of usage facts that will become a charge at the end of the period, and the number lives in at least three places: your meter, your payment provider’s meter and, eventually, an invoice.

This guide shows how to refund usage-based billing in Elixir properly: by correcting the usage fact itself, append-only, so the history still explains every invoice. We will use Aurora Meter, the free MIT metering library for Phoenix, for the ledger, then cover what happens at Stripe, where the rules are stricter than most people expect.

Why you should never edit a usage row

The tempting fix is an UPDATE on the usage table, or a DELETE of the bad rows. Both work until the first dispute. Then you have an invoice that says 12,400 tokens, a database that says 9,400, and no record of why. Three months later nobody remembers the support ticket, and an auditor or an annoyed customer is asking you to reconcile the two.

Usage billing wants the same rule accounting has used for centuries: you never erase an entry, you post a correcting entry next to it. The original fact stays, a correction reduces it, and the net is what you bill. That gives you:

  • An explanation for every number. The invoice total equals usage minus corrections, and both lists are queryable.
  • Safe concurrency. Two support agents crediting the same request at once cannot hand back more than was charged, if the ledger checks the bound under a lock.
  • Idempotency. A retried credit must not credit twice.
  • Correct periods. A September overcharge fixed in October should change September, not October.

Step 1: Record billable usage as durable facts with ids

You can only correct something that has an identity. In Aurora Meter, usage you invoice goes through AuroraMeter.record/4 with a caller-supplied id, rather than the fire-and-forget track/4 counter. Declare the feature as event-sourced and set up the plan:

# config/config.exs
config :aurora_meter,
  repo: MyApp.Repo,
  pubsub: MyApp.PubSub,
  plans: MyApp.Plans,
  feature_sources: %{tokens: :events}
defmodule MyApp.Plans do
  use AuroraMeter.Plans

  plan :scale do
    price 4_900
    metered :tokens, included: 1_000_000, unit_price: 1
  end
end

Then record each billable unit of work once, keyed by something stable from your own domain, such as the request id:

defmodule MyApp.Billing do
  def record_tokens(org, request_id, tokens, finished_at, model) do
    AuroraMeter.record(org, :tokens, tokens,
      id: request_id,
      occurred_at: finished_at,
      dimensions: %{"model" => model}
    )
  end
end

A retry with the same id and payload returns :duplicate instead of charging twice. If you are metering AI calls, our post on LLM token billing in Elixir covers where in the request lifecycle to call this.

Step 2: Refund part or all of one fact with correct/4

A correction is a second fact that points at the first. The quantity is the size of the reduction, always a positive integer:

AuroraMeter.correct(org, "req_8f2c", 3_000,
  id: "refund:SUP-118:req_8f2c",
  metadata: %{"ticket" => "SUP-118", "reason" => "upstream timeout"}
)
#=> {:ok, %AuroraMeter.Event{kind: :correction, quantity: 3_000,
#      original_event_id: "req_8f2c"}, :inserted}

What the library guarantees, and why each one matters for a refund:

  • Corrections can never exceed the original. The check runs while the original row is locked with SELECT ... FOR UPDATE, so two agents crediting the same request at the same moment cannot give back more than was charged. The loser gets {:error, {:invalid, [quantity: :exceeds_original]}}.
  • A repeated correction id is a duplicate, not a second credit. Build the id from the ticket and the original ("refund:SUP-118:req_8f2c") and a double click in your admin panel is harmless.
  • A correction belongs to the original’s period. It carries the original’s feature, plan attribution and dimensions, and it never reprices.
  • A correction of a correction is refused. You always correct the original, so the chain stays one level deep and easy to read.

The durable total for a period is usage less corrections. A fully reversed 10-unit fact reads quantity: 0 with events: 2, so you can still see that something happened and was undone.

Step 3: Fix the wrong dimension or time with replace/4

Sometimes nothing should be refunded, the fact is just wrong: it was attributed to the expensive model when the cheap one served it, or it carries the wrong timestamp and landed in the wrong period. correct/4 deliberately refuses dimensions: and occurred_at:, because changing those is a different operation. Use replace/4, which writes a full reversal and a corrected fact in one transaction:

AuroraMeter.replace(org, "req_8f2c", %{
    quantity: 1_420,
    occurred_at: ~U[2026-10-14 09:12:00Z],
    dimensions: %{"model" => "small"}
  },
  id: "fix:SUP-121:req_8f2c"
)
#=> {:ok, %{correction: %AuroraMeter.Event{}, replacement: %AuroraMeter.Event{}}, :inserted}

The replacement gets the correction’s id with ~r appended unless you pass :replacement_id, which is what makes a retry of the pair idempotent.

Step 4: Wrap it in a support action people can use safely

Support agents should not be typing correct/4 into a remote console. Give them one context function that validates the request, records who did it, and turns every library error into something a human can act on:

defmodule MyApp.Billing.Refunds do
  alias MyApp.Billing.AuditLog

  @doc "Credit `qty` units of one recorded request back to `org`."
  def credit_usage(org, request_id, qty, ticket, actor)
      when is_integer(qty) and qty > 0 do
    with {:ok, original} <- fetch_original(org, request_id),
         {:ok, correction, status} <-
           AuroraMeter.correct(org, request_id, qty,
             id: "refund:#{ticket}:#{request_id}",
             metadata: %{"ticket" => ticket, "actor" => actor}
           ) do
      if status == :inserted do
        AuditLog.write!(actor, :usage_credited, %{
          org: org,
          request_id: request_id,
          quantity: qty,
          period_start: original.period_start,
          ticket: ticket
        })
      end

      {:ok, correction, status}
    else
      {:error, :not_found} ->
        {:error, "No billable usage recorded under #{request_id}."}

      {:error, {:invalid, [quantity: :exceeds_original]}} ->
        {:error, "That is more than is left to credit on #{request_id}."}

      {:error, {:not_found, :original}} ->
        {:error, "No billable usage recorded under #{request_id}."}

      {:error, reason} ->
        {:error, "Could not credit usage: #{inspect(reason)}"}
    end
  end

  defp fetch_original(org, request_id) do
    case AuroraMeter.Events.get(org, request_id) do
      {:ok, %AuroraMeter.Event{kind: :usage} = event} -> {:ok, event}
      {:ok, _correction} -> {:error, "#{request_id} is a correction; credit the original."}
      other -> other
    end
  end
end

Two choices here are worth copying. The tenant (org) comes from the authenticated support context, never from a form field the agent can type into. And the audit row is written only on :inserted, so a retried click does not log a second refund that never happened.

Step 5: Show the customer the corrected number immediately

Nothing builds trust like the usage meter visibly dropping while the customer is still on the support chat. A correction publishes {:aurora_meter, :event, %{kind: :correction, quantity: q}} on the tenant’s topic, with a positive q, so consumers subtract it rather than adding a negative they did not expect. In a LiveView that already shows usage:

def mount(_params, _session, socket) do
  org = socket.assigns.current_org
  if connected?(socket), do: AuroraMeter.LiveView.subscribe(org)
  {:ok, assign(socket, quota: AuroraMeter.quota(org, :tokens))}
end

def handle_info({:aurora_meter, :event, %{kind: :correction}}, socket) do
  {:noreply,
   socket
   |> assign(quota: AuroraMeter.quota(socket.assigns.current_org, :tokens))
   |> put_flash(:info, "A usage credit was applied to your account.")}
end

def handle_info(message, socket) do
  {:noreply, AuroraMeter.LiveView.assign_quota(message, socket, :quota, :tokens)}
end

Pass @quota to <.usage_meter quota={@quota} /> and the bar moves on its own.

Step 6: The hard part, getting the refund to Stripe

Your ledger is now right. Stripe’s might not be. With Stripe Billing Meters, usage reaches Stripe as meter events, and the meter event adjustment API has two rules that shape everything:

  1. It cancels a whole event by its identifier. There is no partial adjustment. To take 3,000 tokens off a 4,420 token event, you cancel the event and send a new one for 1,420.
  2. It only works within 24 hours of Stripe receiving the event. Stripe’s documentation for cancel.identifier says you can only cancel events within 24 hours of receipt. The window starts when Stripe received the event, not when the usage happened.

The second rule is the one that catches people. Most refund requests arrive days after the usage, so for anything but a prompt correction the meter event can no longer be touched. At that point your options are money-side, not usage-side:

  • Invoice not finalized yet: add a negative customer balance transaction, which Stripe applies to the next invoice.
  • Invoice already finalized or paid: issue a credit note against it, or refund part of the payment.
# A $3.00 credit applied to the customer's next invoice (amount in cents, negative = credit)
curl https://api.stripe.com/v1/customers/cus_123/balance_transactions \
  -u "$STRIPE_SECRET_KEY:" \
  -d amount=-300 \
  -d currency=usd \
  -d description="Usage credit, ticket SUP-118"

Never try to “refund” by sending a negative meter event. Stripe reports a negative cycle total as zero anyway, and you lose the one-to-one link between your facts and theirs.

What Aurora Meter Pro does with a correction

The free core records corrections and hands every one of them to the export seam with a reason attached. It does not talk to Stripe. Aurora Meter Pro does, and it follows the rules above:

  • Inside the window (23 hours by default, an hour under Stripe’s documented 24, set by :adjustment_window_seconds), it cancels the identifier currently standing for that fact and, if the net is above zero, sends the net under a derived identifier. Both idempotency keys come from the outbox item’s own identifier, so a retry repeats both and Stripe deduplicates. A crash between the two requests under-reports until the retry lands, which is the safe direction.
  • Outside the window, the item is quarantined with quarantine_reason: "provider_window_closed" and one manual_adjustment reconciliation item is opened, in the same transaction, carrying the customer, meter, period, original identifier and the net quantity that should stand. A credit that silently disappears is the failure that matters here, so it is turned into a to-do instead.

Working the to-do list is two calls:

AuroraMeter.Pro.Reconcile.list_items(tenant: org, kind: "manual_adjustment")

AuroraMeter.Pro.Reconcile.resolve_item(
  item.id,
  "Balance credit of $3.00 on cus_123 for 3,000 tokens; ticket SUP-118",
  actor: "support@myapp.com"
)

Resolving changes no counters and grants nothing. It records that a human made the adjustment and how, which is exactly the audit trail you want when the customer’s finance team asks.

Step 7: Test the rules you depend on

Refund code runs rarely and matters a lot, which is the perfect recipe for a regression nobody notices. Pin the guarantees down in ExUnit:

defmodule MyApp.Billing.RefundsTest do
  use MyApp.DataCase, async: false

  alias MyApp.Billing.Refunds

  setup do
    org = "org_test"
    {:ok, _} = AuroraMeter.subscribe(org, :scale)

    {:ok, event, :inserted} =
      AuroraMeter.record(org, :tokens, 4_420,
        id: "req_1",
        occurred_at: DateTime.utc_now()
      )

    %{org: org, event: event}
  end

  test "a credit reduces the period total", %{org: org, event: event} do
    assert {:ok, _, :inserted} = Refunds.credit_usage(org, "req_1", 3_000, "SUP-1", "amy")
    assert AuroraMeter.Events.total(org, :tokens, event.period_start) == 1_420
  end

  test "a retried credit is not applied twice", %{org: org, event: event} do
    assert {:ok, _, :inserted} = Refunds.credit_usage(org, "req_1", 3_000, "SUP-1", "amy")
    assert {:ok, _, :duplicate} = Refunds.credit_usage(org, "req_1", 3_000, "SUP-1", "amy")
    assert AuroraMeter.Events.total(org, :tokens, event.period_start) == 1_420
  end

  test "credits cannot exceed what was charged", %{org: org} do
    assert {:ok, _, :inserted} = Refunds.credit_usage(org, "req_1", 4_000, "SUP-1", "amy")
    assert {:error, msg} = Refunds.credit_usage(org, "req_1", 500, "SUP-2", "ben")
    assert msg =~ "more than is left"
  end
end

The setup in the testing usage-based billing post (frozen clocks, manual flushes, sandbox allowances) applies unchanged.

Honest limits

  • Corrections fix usage, not prices. If a customer was on the wrong plan, that is a plan change plus a money credit, not a usage correction. Corrections never reprice by design.
  • Stripe’s 24 hour window is not yours to change. Most refunds will end up as balance credits or credit notes. Plan your support process around that, not around the happy path.
  • Prepaid credits are a separate ledger. If the customer paid from a prepaid balance, give the money back with a credit grant (with a stable reference:), as described in the credits docs, rather than only correcting usage.
  • Overage pricing is computed on the net. With a graduated price (see overage billing in Phoenix), a correction can move a customer back under a tier boundary, so the money refunded is not always quantity times one unit price. Let the invoice compute it.

Or skip the plumbing

The free core gives you the append-only ledger, correct/4, replace/4, the bound check under a lock and the live LiveView update: read the Aurora Meter docs or grab it from GitHub (MIT). If you also want the Stripe half handled, including the cancel-and-resend inside the window and the reconciliation queue outside it, Aurora Meter Pro is licensed per app from $99/mo with a 14-day free trial, one trial per customer. Building the SaaS around it too? The phx_saas template brings auth, Stripe checkout and email.

Summary

To refund usage-based billing without losing the story behind your invoices, never edit or delete usage. Record billable usage as durable facts with your own ids, reduce them with correct/4, restate them with replace/4 when a dimension or time was wrong, and wrap both in one support function that logs who did what. Show the corrected meter live, and accept that at Stripe a meter event can only be cancelled whole and only within 24 hours of receipt: after that, the refund is a balance credit or a credit note, and it should land on a reconciliation list rather than in someone’s memory.