Let customers top up, and top them up automatically

Selling prepaid credit through Stripe Checkout, charging a saved card when the balance runs low, and handling the two events everybody forgets: refunds and chargebacks.

Uses Pro 14 min Advanced

The core’s ledger holds prepaid credit. This guide is the Stripe half: getting money into that ledger, keeping it topped up without the customer thinking about it, and taking it back out when a payment is reversed.

If you have not read Sell credit up front, spend it per request, start there. This page assumes you know what a grant, a hold and a reference are.

#1. Selling credit

def top_up(conn, %{"cents" => cents}) do
  org = conn.assigns.current_org

  {:ok, url} =
    AuroraMeter.Pro.Credits.checkout(org, String.to_integer(cents),
      success_url: url(~p"/billing?topped_up=1"),
      cancel_url: url(~p"/billing")
    )

  redirect(conn, external: url)
end

This is a payment mode Checkout Session, not a subscription. The amount is in cents, and it is clamped to credits_min_cents and credits_max_cents so a crafted form cannot buy one cent or a million dollars of credit.

For the buttons on your page:

AuroraMeter.Pro.Credits.presets_cents()   # => [1_000, 2_500, 5_000, 10_000]
config :aurora_meter_pro,
  credits_presets_cents: [1_000, 2_500, 5_000, 10_000],
  credits_min_cents: 500,
  credits_max_cents: 100_000

#2. The credit lands from the webhook, never the redirect

The customer comes back to your success_url, and the balance may not have moved yet.

Do not credit the ledger from that redirect. Anyone can visit a URL. If the redirect grants credit, a bookmark is free money.

The grant happens when payment_intent.succeeded arrives, keyed on the PaymentIntent id:

Stripe: pi_3abc succeeded
   ->  grant(tenant, 25_000_000, reference: "stripe:pi_3abc")

Delivered twice, it grants once. Delivered a week late, it still grants once. That single property is what makes the whole thing safe, and it is why the reference is the PaymentIntent id and never a timestamp.

On the success page, say the top up is being confirmed and let the live balance update itself. AuroraMeter.Credits.subscribe/1 pushes the new figure the moment the grant commits, so the page corrects itself a second later without a refresh.

#3. Automatic recharge

AuroraMeter.Pro.Credits.update_auto_top_up(org, %{
  auto_top_up_enabled: true,
  threshold_micro: 5_000_000,    # when available drops below $5.00
  amount_cents: 2_500            # charge $25.00
})

The card charged is the one saved by their last successful top up, charged off session: no browser, no customer present.

Two things trigger it, and you want both:

config :aurora_meter,
  credits_low_balance_handler: &AuroraMeter.Pro.Credits.on_low_balance/1
config :my_app, Oban,
  plugins: [{Oban.Plugins.Cron, crontab: [
    {"*/5 * * * *", AuroraMeter.Pro.Credits.AutoTopUpSweeper},
    {"*/30 * * * *", AuroraMeter.Pro.Credits.Expirer}
  ]}]

The handler fires on a crossing, which is the right moment. The sweeper catches accounts already sitting below the line: one that crossed while auto top up was switched off, or during an outage. Without the sweeper, such an account stays stranded below its threshold for ever, because there is no second crossing to come.

#Charging exactly once

This is the hardest thing in the package, and it is worth understanding, because the failure mode is charging a customer twice.

The charge parameters, the idempotency key and the start time are written to the account before the charge is attempted. An uncertain retry keeps exactly those parameters, even if the customer has since changed their settings. Once a PaymentIntent id is known, recovery retrieves it rather than creating another charge. Its successful payment grants credit once, keyed on that id.

An unknown outcome that is at least 23 hours old returns :payment_reconciliation_required. No new charge is created just because Stripe’s idempotency window has elapsed.

Preserve pending state and find out from Stripe what happened before you clear it by hand. “It looked stuck so I reset it” is how a customer gets charged twice.

#What counts as a failure

Not everything that goes wrong is the card’s fault, and treating it as such is how a few minutes of Stripe being unwell disables automatic recharge for every customer with a low balance.

What came back Verdict What happens
a decline (card_error, 402) definitive, the card said no counted as a failure, the key is cleared
a timeout or dropped connection uncertain logged, not counted, the key stays
a 500 or a 429 uncertain logged, not counted, the key stays
status: "processing" not a failure at all skipped, the key stays

That last row is ACH, SEPA and BECS. The money is on its way and the succeeded webhook will grant it. Treating it as a failure starts a second debit for the same top up as soon as the cooldown lapses.

After three definitive failures (auto_top_up_max_failures), automatic recharge switches itself off and the owner is told. Only one call turns it back on:

{:ok, _account} = AuroraMeter.Pro.Credits.re_enable_auto_top_up(org)

Nudging the threshold does not re-arm a stopped card, and neither does a successful manual top up. Both used to, which meant a customer who had just been refunded could silently re-arm their own card.

#4. Refunds and chargebacks

You refund $25 in the Stripe dashboard. charge.refunded arrives, and Pro takes the credit back out of the ledger with Credits.reverse/4. That is never refused for want of balance, because the money has already left Stripe and a negative balance is the honest record of a debt.

Automatic recharge is switched off at the same time, and before the reversal is written, so the balance dropping cannot fire the low balance hook and immediately charge the card you are in the middle of refunding.

Subscribe to all of these, not just the first:

Event What it means
charge.refunded money went back
refund.created .updated .failed a refund can fail after succeeding, or be cancelled
charge.dispute.funds_withdrawn a chargeback took the money
charge.dispute.funds_reinstated you won the dispute

Miss charge.dispute.* and a chargeback takes your money while the customer keeps spendable credit, and nothing downstream will ever notice.

Pro reconciles all of those against one combined target: the original grant, capped against the charge’s current refunded total plus every unresolved or lost dispute. Then it writes only the difference. So two separate $10 disputes take $20, a repeated event changes nothing, and a won dispute contributes zero while any remaining refund still reduces the spendable balance.

#A refund that arrives before its own payment

Stripe’s fraud tooling can auto-refund within minutes of a payment, and each event retries on its own schedule, so a refund really can overtake the payment_intent.succeeded it reverses.

Answering “ignore” there would return 200, Stripe would mark the event delivered, and nothing would ever notice: the money left and the credit stayed spendable. So a charge that carries your customer metadata and has no grant yet is refused with a non-2xx, which is Stripe’s cue to redeliver it later, once the grant exists.

Watch your failed and discarded webhook deliveries. That is where this shows up.

#5. Being told what happened

config :aurora_meter_pro, credits_handler: &Parsely.Billing.credit_event/1
defmodule Parsely.Billing do
  require Logger

  def credit_event({:granted, txn}) do
    Parsely.Mailer.receipt(txn.tenant_key, txn.amount)
  end

  def credit_event({:low_balance, %{tenant_key: key, available: available}}) do
    Parsely.Mailer.low_balance(key, available)
  end

  def credit_event({:auto_top_up_failed, key, reason, attempt}) do
    Logger.warning("auto top-up #{attempt} failed for #{key}: #{inspect(reason)}")
  end

  def credit_event({:auto_top_up_disabled, key, reason}) do
    Parsely.Mailer.auto_top_up_off(key, reason)
  end

  def credit_event(_other), do: :ok
end

Keep the catch-all clause. New event kinds arrive in minor versions, and an unmatched one should not crash a webhook.

#6. Closing an account

AuroraMeter.Pro.Credits.close_account(org)

This stops automatic recharge and detaches the saved card. It does not refund the remaining balance.

If your policy is to refund a paid top up, issue the real Stripe refund and let its webhook reconcile the ledger. Do not also write a manual reversal for the same refund: that debits the customer twice.

#7. Keeping Stripe out of your tests

# config/test.exs
config :aurora_meter_pro,
  credits_stripe_client: AuroraMeter.Pro.Credits.StripeClient.Fake,
  audit_log_writer: :sync

The fake records every call and answers like Stripe does, including answering a repeated idempotency key with the PaymentIntent it made the first time. That is what makes it possible to test that a retried charge does not take the money twice.

The test worth copying into your own suite:

alias AuroraMeter.Pro.Credits.StripeClient.Fake

setup do
  Fake.reset()
  :ok
end

test "a top-up funds the ledger exactly once" do
  org = org_fixture()

  event = %{
    "type" => "payment_intent.succeeded",
    "data" => %{"object" => %{
      "id" => "pi_1",
      "amount_received" => 2_500,
      "customer" => "cus_1",
      "payment_method" => "pm_1",
      "metadata" => %{"tenant_key" => MyApp.tenant_key(org), "kind" => "top_up"}
    }}
  }

  {:ok, _} = AuroraMeter.Pro.Credits.handle_event(event)
  {:ok, _} = AuroraMeter.Pro.Credits.handle_event(event)   # Stripe redelivered

  assert AuroraMeter.Credits.available(org) == 25_000_000  # not 50_000_000
end

That second handle_event/1 is the whole point. A redelivery is not a rare edge case: Stripe retries any non-2xx for days.

#Before you go live

  • The success page does not grant credit.
  • Both triggers are wired: the low balance handler and the sweeper.
  • The webhook is subscribed to the refund and dispute events, not only to payment_intent.succeeded.
  • You have an alarm on failed and discarded webhook deliveries.
  • You have refunded a test payment and watched the balance come back down.
  • Currencies match. The top up API refuses zero-decimal and three-decimal Stripe currencies before charging, and it does not convert between currencies.

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.