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.
#What to read next
- Get overage onto the invoice if you also sell a metered subscription.
- Ship it: the production checklist for the dashboard, the audit log and the alarms.
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.